default eye-catch image.

RDSの機能など

参考書を1周した. 普段RDSを道具として使っているだけでは経験しない知識を得ることができた. インフラ系の仕事をしないと使わない可能性がある知識もあるが、アプリケーションエンジニアとしては、 RDSがここまでやってくれると知っていることで無駄な機能を作り込んだり、余計な心配をしなくて済む. [arst_toc tag=\"h3\"] 可用性 スケールアウトすることで何が冗長化されるのか. いざフェイルオーバーが発生したときどういう挙動になるのか. まとめ プライマリインスタンスとスタンバイレプリカを別AZに配置することで可用性を得る プライマリとスタンバイの間で常にデータ同期がおこなわれる プライマリに障害が発生した場合スタンバイにフェイルオーバーすることでDB接続を継続する スタンバイはトラフィック処理しない. 読み取り性能を上げるためにはリードレプリカを追加する スタンバイがある場合、スタンバイを対象にRDSのスナップショット取得がおこなわれ、プライマリのトラフィックに影響を与えない マルチAZの場合、スタンバイとのデータ同期によりシングル構成よりも書き込み・コミットでわずかにレイテンシが上がる AZの変更 プライマリのスナップショットを作成後、セカンダリとして復元し同期 AZ変更時はプライマリのパフォーマンスに影響する フェイルオーバー RDSの外からはエンドポイントでつなぐ プライマリに障害が発生した場合、エンドポイントの先が自動的にスタンバイにつなぎ変わる 切り替えにかかる時間は60秒-120秒. DNSキャッシュのTTLを60秒以内にしておくことが推奨されている AWSコンソールから手動でフェイルオーバー時の挙動を確認できる パフォーマンス スケールアップで何が良くなるのか。スケールアウトではどうか。 スケールアップさせることを前提にできるのか。 まとめ データベースのパフォーマンスは主にデータの読み書きのパフォーマンス 汎用SSD.3IOPS/GB. バースト(一時的に)100-10,000IOPS. プロビジョンドIOPS. 常に1,000-30,000IOPS. ストレージ容量の残量が10%以下の状態が5分以上続いた場合、5GBまたは割り当て容量の12%のどちらか大きい方が自動的に追加される 容量を頻繁に拡張できるわけではない.1度変更すると6時間変更できない. Storage Auto Scalingに頼るべきではない リードレプリカ 読み込み性能は、プライマリを複製したリードレプリカを増やすことで対応. トラフィックがリードレプリカに分散される 書き込み性能は、スケールアップにより対応 プライマリとリードレプリカの同期は非同期. 微妙に異なる. プライマリのスナップショットからリードレプリカが作成され複製される.従って作成直後は異なる リードレプリカは最大5個 プライマリとリードレプリカのインスタンスサイズは異なっていても良い 手動でリードレプリカをプライマリに昇格可能 マルチAZ可能. DR対応で別リージョンにリードレプリカを作成可能. リードレプリカのエンドポイントはそれぞれ異なる.負荷分散する場合、Route53等で1つのDNSレコード先を分散させる RDBMSごとの制約 SQLServerの場合、特定エディション以上でリードレプリカを使用可能 SQLServerの場合、マルチリージョン、マルチAZリードレプリカを作成不可 Oracleの場合、特定エディション以上でリードレプリカを使用可能 Oracleの場合、OracleのActiveDataGuargeにより同期がおこなわれる RDS Proxy アプリケーションがDBにアクセスする際、一度作成したコネクションをプーリングして使い回す機能 昔、LambdaからRDSにつなぐ際、コネクションがプールされずすぐに最大接続数を超過していたがこれで解決した RDS Proxyはプライマリインスタンスのみ対応 セキュリティ アプリケーションが個人情報の暗号化を意識する必要があるのか。RDSが透過的に面倒を見てくれるのか。 まとめ RDSを設置するVPCには少なくとも2つのサブネットが必要 VPCのACL、SGでアクセス制御する SGの送信元にはSGを指定できる.SGとSGの接続を定義できる 暗号化 データ格納時の暗号化と通信時の暗号化の2つ KMSのキーを使用して格納するデータを暗号化. KMSキーを別管理することでRDS内のデータが漏れても保護できる KMS暗号化は透過的におこなわれる. アプリケーションは特に意識しなくても良い 暗号化の対象は以下の通り DBインスタンスに格納するデータ 自動バックアップ リードレプリカ スナップショット ログファイル DBインスタンス作成時にのみ暗号化可能. 未暗号化インスタンスのスナップショットを作成して復元時に暗号化 プライマリだけ、リードレプリカだけ、のように非対称に暗号化することはできない KMSはリージョンを跨げないためリージョン間スナップショットを取る場合はコピー先のリージョンでコピー元とは異なるKMSキーを指定する必要がある SSL/TLSにより伝送中データの暗号化 AWSからルート証明書をDLしアプリケーション側でSSL/TLS通信時に取得したルート証明書を使う ルート証明書は定期的に失効する. 都度ダウンロードして更新すること IAMによるDBアクセス認証 MySQLとPostgreSQLに限り、IAMを使用したDBアクセス認証を利用できる. RDSへアクセス可能なIAMロールを作成. アプリケーション側は作成されたIAMロールを使ってRDSにアクセス アプリケーション側で接続情報を管理しなくてもよい 監査ログ DBエンジンがもつ監査ログ機能を利用できる. 監査ログはCloudWatchに転送され、管理・監視できる コスト アプリケーション側をチューニングする人的コストと、インスタンスに使うコスト。 何に料金がかかるということを把握して、アプリケーション側でやるべきこと/AWS側に振ることを意識する. まとめ RDSで発生するコストはインスタンス料金、ストレージ料金、データ通信料金 インスタンス料金 コストは1秒単位.ただし1時間未満は最低10分から. 2AZに配置した場合、リードレプリカを設置した場合、インスタンス数が2倍になるのでインスタンス料金も2倍になる DBエンジンの種類によって若干インスタンス料金が異なる.MySQL<postgreSQL<oracle 1年または3年の前払い制(リザーブドインスタンス)により割安になる.損益分岐点あり インスタンスを停止するとインスタンス料金の課金は止まる.ただし1週間止めておくと自動的に起動してしまう. ストレージ料金 インスタンスを止めていてもストレージ料金の課金は止まらない 利用中のストレージサイズと同サイズまでのバックアップには課金されない.それを超えたところから課金される.ただし超えた分は安い データ転送料金 RDSへのINは無料 RDSからVPC外部、またはインターネットへの通信は課金される. 通常VPC内部でEC2とやりとりする場合は無料だが、VPC外部とやりとりする場合注意 メンテナンス 作ったアプリが保守フェーズに移行した後、アプリケーション側は何を意識しなければならないか. まとめ AWSが実施するメンテナンスの実行時間を指定できる.(メンテナンスウインドウ) 22:00-06:00の間の30分. 大きなメンテナンスの場合1時間かかる場合がある.余裕をみて1時間設定する メンテナンスウインドウ期間中、いくつかのメンテナンスによりインスタンスが一時的にオフラインになる メンテナンス種別は「必須」と「利用可能」. 「必須」は無期限延期できない. 「利用可能」はできる. アプリケーションの動作に影響がありそうなものは開発環境で事前に検証すること マルチAZのメンテナンス まずスタンバイについてメンテナンスを実行 スタンバイをプライマリに昇格. 降格した元プライマリにメンテナンスを実行.そのままスタンバイになる 全体としてインスタンスがオフラインになることがない. ストレージ追加、インスタンスタイプの変更は任意またはメンテナンスウインドウ DBエンジンのアップグレード メジャーバージョンアップはユーザ自身が実施 マイナーバージョンアップは設定次第で自動でやってくれる. 手動でも可. パラメータグループ 設定値(パラメータ)のグループ. 例えばMySQLのconfに書くような設定値が集まったもの. DBエンジンごとに様々なパラメータが存在する デフォルトパラメータグループ ユーザは変更できない. ユーザが独自のパラメータグループを作成しデフォルトパラメータをオーバーライド すぐに適用される「動的パラメータグループ」.再起動が必要な「静的パラメータグループ」 追加設定はオプショングループ.デフォルトのパラメータは変更できず,ユーザが作成してオーバーライド バックアップ これも, 保守フェーズに移行した後アプリケーション側で何を意識しないといけないか. 自動バックアップと手動バックアップ 自動バックアップ 自動的にスナップショットを保存. 保存日数はデフォルト7日.0(無効)-35日. スナップショットは不可視のS3に保存される. 初回のスナップショットはフル. 2回目以降は差分. バックアップはメンテナンスウインドウで作成される. シングルAZの場合一時的にオフラインになる. マルチAZの場合オフラインにならない 手動バックアップ 任意のタイミングでバックアップできる. 手動バックアップは自動的に削除されない. DR目的で別リージョンへのスナップショットコピー 別リージョンに手動でスナップショットをコピーできる 暗号化用KMSキー、オプショングループは自動でコピーされないので自力でコピー先に作る 別アカウントとスナップショット共有 手動バックアップしたスナップショットを別アカウントと共有できる 暗号化済みの場合、KMSキーを共有先にアクセス許可する 暗号化していない場合、格納された個人情報にアクセス可能となる スナップショットの復元 既存のRDSインスタンスに復元できない.新しいRDSインスタンスを復元する エンドポイントが変わるのでアプリケーション側の再設定が必要 パラメータグループはインスタンスに紐づくため復元時に復元元のパラメータグループを使用する PITR(ポイントインタイムリカバリ) スナップショットとは別にトランザクションログがS3に5分単位で保存される スナップショット復元と合わせて最短で5分前までの状態に復元が可能. S3へのエクスポート スナップショットからS3にエクスポートできる 不可視のS3ではなく、Amazon Parquet形式でS3バケットにデータをエクスポートできる Athena、Redshift等別サービスからS3上のファイルを検索、分析できる モニタリング 作ったアプリがショボすぎて速度が出ない! ピンチを救うAWSの機能. 保守フェーズ移行, 劣化やユーザ数増加により受けた影響の調査. 他. インスタンスが効率的に使われているかを調べるためにリソース使用状況を監視できる CloudWatchにメトリクスが展開される. CloudWatchAlarmによりメトリクスの変化に伴ってSNS通知などアクションを実行できる DBエンジンが出力するログはCloudWatchLogsに転送できる ログに含まれる特定のエラー文字列を見つけてSNS通知するなどのユースケース 拡張モニタリングにより詳細なリソースデータを監視できる. パフォーマンスインサイト. パフォーマンスに関するデータを可視化する. ユーザ自身が可視化ツールを用意しなくてもある程度は確認できる スロークエリ、実行計画の確認などができる. パフォーマンスチューニングの初手に使える フェイルオーバーや再起動などをトリガーとしてSNS通知できる

default eye-catch image.

Terraformを使ってAWSにWebアプリケーションの実行環境を立てる (EC2立てるまで)

Webアプリケーション実行環境をIaCで管理したい. Terraformでクラウド構成を作ってAnsibleでミドルウェアをインストールしたい. BeanstalkやLightsailのようなPaaSではなくTerraformを使ってVPCから自前で作ってみる. この記事はEC2を立てるまでが範囲. 次の記事でAnsibleを使って立てたEC2にミドルウェアをインストールする. [arst_toc tag=\"h4\"] この記事で紹介する範囲 この記事ではTerraformを使ってAWS上に以下の構成を作るまでを書いてみる. とはいえTerraformの習得が8割くらいのモチベなので実用性はあまり重視しない. サブネットをプライベートとパブリックに分けてみたい. プライベートにDB(MySQL), パブリックにWebサーバ(nginx). ひとまずALBは配置しない. Terraformの導入 Ansibleもそうだけれども, アプリを保守している期間って割と長いもので、 その間, 構成管理ツール側のバージョンが上がってしまう傾向がある. そうすぐに古い書き方が使えなくなることはないが, 警告が出まくって気分がよくない. 構成管理ツールの古いバージョンを残しておきたい, どのバージョンを使うか選びたい, という期待がある. rbenvやpyenvのようにTerraform自体のバージョンを管理するtfenvをインストールしておき, この記事を書いた日の最新である 1.0.3 をインストールすることにする. $ brew install tfenv $ tfenv --version tfenv 2.2.2 $ tfenv list-remote .1.0-alpha20210714 1.1.0-alpha20210630 1.1.0-alpha20210616 1.0.3 1.0.2 1 ... $ tfenv install 1.0.3 ... $ tfenv list 1.0.3 $ tfenv use 1.0.3 Switching default version to v1.0.3 Switching completed $ terraform version Terraform v1.0.3 on darwin_amd64 git secretsの導入 AWSのcredentialsなどを誤ってcommitしてしまう事故を防ぐためにgit secretsを導入する. commit時に内容を検証してくれて, もしそれらしきファイルがあればリジェクトしてくれる. どこまで見てくれるのか未検証だけれども入れておく. Laravelの.env_staging等に書いたcredentialsがどう扱われるか後で検証する. $ brew install git-secrets $ git secrets --install ✓ Installed commit-msg hook to .git/hooks/commit-msg ✓ Installed pre-commit hook to .git/hooks/pre-commit ✓ Installed prepare-commit-msg hook to .git/hooks/prepare-commit-msg $ git secrets --register-aws OK ディレクトリ構成 勉強用の小さな環境を作るのだけれども, 今後の拡張性については考慮しておきたい. 割と規定されている傾向があるAnsibleと比較して,Terraformは自由な印象. 以下の記事を参考にさせて頂きました. Terraformなにもわからないけどディレクトリ構成の実例を晒して人類に貢献したい iac ├── dev │ ├── backend.tf │ ├── main.tf -> ../shared/main.tf │ ├── provider.tf -> ../shared/provider.tf │ ├── versions.tf -> ../shared/versions.tf │ ├── terraform.tfvars │ └── variables.tf -> ../shared/variables.tf └── shared ├── main.tf ├── provider.tf ├── variables.tf └── modules ├── vpc │ ├── eip.tf │ ├── internet_gateway.tf │ ├── nat_gateway.tf │ ├── routetables.tf │ ├── subnet.tf │ ├── vpc.tf │ ├── outputs.tf │ └── variables.tf └── ec2 ├── ec2.tf ├── keypair.tf ├── network_interface.tf ├── security_group.tf ├── outputs.tf └── variables.tf tfstateの保存先の定義 tfstate は Terraformが管理しているリソースの現在の状態を表すファイル. terraformは「リソースを記述したファイル」と「現在の状態」の差分を埋めるように処理を行うが, いちいち「現在の状態」を調べにいくとパフォーマンスが悪化するため, ファイルに保存される. (確かにAnsibleは毎回「現在の状態」を調べにいっているっぽく,これが結構遅くて毎回イライラする) デフォルトだとローカルに作られるが, それだとチーム開発で共有できないので, S3等に作るのが良くあるパターン. Terraformでは\"バックエンド\"という概念で扱われる. \"バックエンド\"を以下のように記述する. バックエンドの定義はterraformの前段にあり, S3 bucketとDynamoDB tableを手動で作っておく必要がある. 変数を使うことができないのでハードコードしないといけない. 議論があるらしい. key,secretを書く代わりにprofileを書くことで, 構成管理可能になる. (同じprofile名をチームで共有しないといけない...) backendをS3にする際にS3のbucketをどう作るか問題はいろいろ議論があるようで, いずれ以下の記事を参考にしてよしなにbucketを作れるようにしたい. dynamodb_tableを設定すると、そこにロックファイルを作ってくれるようになる. 多人数で同じ構成管理を触るときに便利. Backend の S3 や DynamoDB 自体を terraform で管理するセットアップ方法 terraform { backend \"s3\" { region = \"ap-northeast-1\" profile = \"ikuty\" bucket = \"terraform-state-dev\" key = \"terraform-state-dev.tfstate\" dynamodb_table = \"terraform-state-lock-dev\" } } credentialsの書き方 ルートにある terraform.tfvarsというファイルを置いておくと、 そこに記述した内容を変数に注入することができる. \"注入\"という言葉で良いのか不明だが、定義した変数の初期値を設定してくれる. credentialsを構成管理に登録するのはご法度. terraform.tfvarsを構成管理外として何らかの方法で環境にコピーする. 多くのツールで採用されている「よくあるパターン」. 他に,applyコマンドに直接渡したり, 環境変数で指定したりできるが, Terraform公式は.tfvarsを推奨している. aws_access_key_id = \"AKI*****************\" aws_secret_access_key = \"9wc*************************************\" aws_region = \"ap-northeast-1\" providerの定義 プロバイダとは, 要は\"AWS\",\"Azure\",\"GCP\".. のような粒度の何か. Terraformは結構な種類のプロバイダに対応していて「どのプロバイダを使うか」を定義する. 今回はAWSを使う. dev.tfvarsに記述しておいたCredentialsを変数で受けて設定する. 以下,変数の定義方法, デフォルト値の設定方法を示している. .tfvarsに記述した同名の変数について,terraformが値を設定してくれる. variable \"aws_access_key_id\" {} variable \"aws_secret_access_key\" {} variable \"aws_region\" { default = \"ap-northeast-1\" } provider \"aws\" { access_key = \"${var.aws_access_key_id}\" secret_key = \"${var.aws_secret_access_key}\" region = \"${var.aws_region}\" } エントリポイント Terraformのエントリポイントはルートに置いた\"main.tf\". ディレクトリ構成を凝らないのであれば、main.tf に全てをベタ書きすることもできる. 今回、devやstg, prod のような環境ごとにルートを分ける構成を作りたいのだが、 main.tf 自体は環境ごとに差異が無いことを前提にしている. ./shared/main.tf というファイルを作成し、 各環境ごとの main.tf を ./shared/main.tf の Symbolic Link とする. main.tf でリソースの定義はおこなわない. 同階層の./modules にモジュール定義があるが, main.tf は ./modules以下の各モジュールに変数を渡すだけ. VPCの作成とEC2の作成を各モジュールに分割した. 各モジュールのOutputのスコープはモジュールまでなので、 例えばEC2モジュールからVPCモジュールのVPC IDを直接受け取れない. main.tf はモジュールの上に位置するため、このようにモジュール間で変数を共有できる. module \"vpc\" { source = \"../shared/modules/vpc\" } module \"ec2\" { source = \"../shared/modules/ec2\" vpc_id = module.vpc.myVPC.id private_subnet_id = module.vpc.private_subnet.id public_subnet_id = module.vpc.public_subnet.id } VPCモジュール ./shared/modules/vpc以下にVPCモジュールを構成するファイルを配置する. スコープがVPCモジュールに閉じたローカル変数を定義する. 以下のようにしておくと、モジュール内から local.vpc_cidr.dev のように値を取得できる. locals { vpc_cidr = { dev = \"10.1.0.0/16\" } subnet_cidr = { private = \"10.1.2.0/24\" public = \"10.1.1.0/24\" } } VPCを1個作る. VPCのCIDRは10.1.0.0/16. resource \"aws_vpc\" \"myVPC\" { cidr_block = local.vpc_cidr.dev instance_tenancy = \"default\" enable_dns_support = \"true\" enable_dns_hostnames = \"false\" tags = { Name = \"myVPC\" } } 作ったVPC内にサブネットを2個作る. 1つはPrivate用. もう1つはPublic用. PrivateサブネットのCIDRは10.1.2.0/24. PublicサブネットのCIDRは10.1.1.0/24. AZは両方同じで \"ap-northeast-1a\". map_public_ip_on_launchをtrueとしておくと, そこで立ち上げたEC2に自動的にpublic ipが振られる. resource \"aws_subnet\" \"public_1a\" { vpc_id = aws_vpc.myVPC.id depends_on = [aws_vpc.myVPC] availability_zone = \"ap-northeast-1a\" cidr_block = local.subnet_cidr.public map_public_ip_on_launch = true tags = { Name = \"public-1a\" } } resource \"aws_subnet\" \"private_1a\" { vpc_id = aws_vpc.myVPC.id depends_on = [aws_vpc.myVPC] availability_zone = \"ap-northeast-1a\" cidr_block = local.subnet_cidr.private tags = { Name = \"private-1a\" } } VPCに紐づくInternet Gatewayを作る. resource \"aws_internet_gateway\" \"myGW\" { vpc_id = \"${aws_vpc.myVPC.id}\" depends_on = [aws_vpc.myVPC] tags = { Name = \"my Internet Gateway\" } } Privateサブネットからインターネットに繋ぐために、 PublicサブネットにNAT Gatewayを作りたい. NAT Gateway用のEIPを作る. resource \"aws_eip\" \"nat_gateway\" { vpc = true depends_on = [aws_internet_gateway.myGW] tags = { Name = \"Eip for Nat gateway\" } } PublicサブネットにNAT Gatewayを作る. EIPは上で作成したものを使う. resource \"aws_nat_gateway\" \"myNatGW\" { allocation_id = aws_eip.nat_gateway.id subnet_id = aws_subnet.public_1a.id depends_on = [aws_internet_gateway.myGW] tags = { Name = \"my Nat Gateway\" } } ルートテーブル. いろいろなところで書かれていた内容を試してようやく動くものができた. VPCにはデフォルトで「メインルートテーブル」が作られる. メインルートテーブルはいじっていない. 以下、Private, Publicサブネットそれぞれのためのルートテーブルを定義している. PublicサブネットからInternet Gatewayに繋ぐ. PrivateサブネットからNAT Gatewayに繋ぐ. # Route table for public # public resource \"aws_route_table\" \"public\" { vpc_id = aws_vpc.myVPC.id depends_on = [aws_internet_gateway.myGW] tags = { Name = \"my Route Table for public\" } } # private resource \"aws_route_table\" \"private\" { vpc_id = aws_vpc.myVPC.id depends_on = [aws_internet_gateway.myGW] tags = { Name = \"my Route Table for private\" } } # Route table association # public resource \"aws_route_table_association\" \"public\" { subnet_id = aws_subnet.public_1a.id route_table_id = aws_route_table.public.id } # private resource \"aws_route_table_association\" \"private\" { subnet_id = aws_subnet.private_1a.id route_table_id = aws_route_table.private.id } # Routing for public resource \"aws_route\" \"public\" { route_table_id = aws_route_table.public.id gateway_id = aws_internet_gateway.myGW.id destination_cidr_block = \"0.0.0.0/0\" } # Routing for private resource \"aws_route\" \"private\" { route_table_id = aws_route_table.private.id gateway_id = aws_nat_gateway.myNatGW.id destination_cidr_block = \"0.0.0.0/0\" } EC2モジュール ./shared/modules/ec2以下にEC2モジュールを構成するファイルを配置する. スコープがEC2モジュールに閉じたローカル変数を定義する. main.tfからVPCモジュールのOutputをEC2モジュールに渡す必要があるが、 渡すデータを受けるためにEC2モジュール側で変数を定義しておく必要がある. locals { private = { ip = \"10.1.2.5\" ami = \"ami-0df99b3a8349462c6\" instance_type = \"t2.micro\" } public = { ip = \"10.1.1.5\" ami = \"ami-0df99b3a8349462c6\" instance_type = \"t2.micro\" } } variable \"vpc_id\" { type = string } variable \"private_subnet_id\" { type = string } variable \"public_subnet_id\" { type = string } EC2にアクセスするための鍵ペア. 既に鍵ペアを持っているものとし、その公開鍵を渡す. 以下のようにすると、HostからSSHの-iオプションで秘密鍵を指定して接続できるようになる. resource \"aws_key_pair\" \"deployer\" { key_name = \"deployer\" public_key = \"{公開鍵}\" } EC2に設定するセキュリティグループを作る. この記事では, Private, Publicともに、インバウンドをSSHのみとした. 次の記事でPublicにHTTPを通す. アウトバウンドとして全て通すようにしないとインスタンスから外にアクセスできなくなる(ハマった). # Security group resource \"aws_security_group\" \"web_server_sg\" { name = \"web_server\" description = \"Allow http and https traffic.\" vpc_id = var.vpc_id } # Security group rule SSH(22) resource \"aws_security_group_rule\" \"web_inbound_ssh\" { type = \"ingress\" from_port = 22 to_port = 22 protocol = \"tcp\" cidr_blocks = [\"0.0.0.0/0\"] security_group_id = aws_security_group.web_server_sg.id } resource \"aws_security_group_rule\" \"web_outbound\" { type = \"egress\" from_port = 0 to_port = 0 protocol = \"-1\" cidr_blocks = [\"0.0.0.0/0\"] ipv6_cidr_blocks = [\"::/0\"] security_group_id = aws_security_group.web_server_sg.id } # Security group resource \"aws_security_group\" \"db_server_sg\" { name = \"db_server\" description = \"Allow MySQL traffic.\" vpc_id = var.vpc_id } # Security group rule SSH(22) resource \"aws_security_group_rule\" \"db_inbound_ssh\" { type = \"ingress\" from_port = 22 to_port = 22 protocol = \"tcp\" cidr_blocks = [\"0.0.0.0/0\"] security_group_id = aws_security_group.db_server_sg.id } resource \"aws_security_group_rule\" \"db_outbound\" { type = \"egress\" from_port = 0 to_port = 0 protocol = \"-1\" cidr_blocks = [\"0.0.0.0/0\"] ipv6_cidr_blocks = [\"::/0\"] security_group_id = aws_security_group.db_server_sg.id } ネットワークインターフェース. セキュリティグループはEC2インスタンスではなくネットワークインターフェースに紐づく. EC2(aws_instance)のsecurity_groupsに書けなくてハマった. # public resource \"aws_network_interface\" \"public_1a\" { subnet_id = var.public_subnet_id private_ips = [local.public.ip] security_groups = [ aws_security_group.web_server_sg.id ] tags = { Name = \"public_subnet_network_interface\" } } # private resource \"aws_network_interface\" \"private_1a\" { subnet_id = var.private_subnet_id private_ips = [local.private.ip] security_groups = [ aws_security_group.db_server_sg.id ] tags = { Name = \"private_subnet_network_interface\" } } 最後にEC2. # Web Server resource \"aws_instance\" \"public\" { ami = local.public.ami instance_type = local.public.instance_type key_name = aws_key_pair.deployer.id network_interface { network_interface_id = aws_network_interface.public_1a.id device_index = 0 } credit_specification { cpu_credits = \"unlimited\" } root_block_device { volume_size = 20 volume_type = \"gp2\" delete_on_termination = true tags = { Name = \"web-ebs\" } } tags = { Name = \"Web\" } } # DB Server resource \"aws_instance\" \"private\" { ami = local.private.ami instance_type = local.private.instance_type key_name = aws_key_pair.deployer.id network_interface { network_interface_id = aws_network_interface.private_1a.id device_index = 0 } credit_specification { cpu_credits = \"unlimited\" } root_block_device { volume_size = 20 volume_type = \"gp2\" delete_on_termination = true tags = { Name = \"db-ebs\" } } tags = { Name = \"DB\" } } 実行 作った.tfファイルを再生して環境を構築する. validateでデバッグして、大体できたらplan(DryRun)で変更が正しそうか確認してみた. が、評価しなければわからないものについてはDryRunではわからず、 結局applyが途中で止まって解決しないといけない. ansibleと異なり冪等性が言われていなくて、applyで間違った構成を作ってしまうと、 その先、その構成を修正したとしても上手くいかないことがある. $ cd \"/path/to/dev\" $ terraform validate Success! The configuration is valid. $ terraform plan ... $ terraform apply ... 出来たとして、Publicに立ったEC2のパブリックIPv4をメモる. 疎通確認 Host ->(SSH)-> Web ->(SSH)-> DB を試す. DBから外に繋がるか試す. SSH Agent Forwardを使うと、Web EC2に秘密鍵を置かないで済む. Web側のssh configにForwardAgent yesを指定しておく. Host db HostName 10.1.2.5 User ubuntu ForwardAgent yes いざ. $ ssh-add \"{秘密鍵のパス}\" $ ssh -A ubuntu@{WebのパブリックIPv4} ubuntu@ip-10-1-1-5$ ssh db ubuntu@ip-10-1-2-5$ ping yahoo.co.jp 64 bytes from f1.top.vip.kks.yahoo.co.jp (183.79.135.206): icmp_seq=1 ttl=33 time=14.9 ms 64 bytes from f1.top.vip.kks.yahoo.co.jp (183.79.135.206): icmp_seq=2 ttl=33 time=14.6 ms .. できた..

default eye-catch image.

Laravel8 Jetstreamを導入した状態でsocialiteによるSNS認証を両立させる

Laravel8が大きく変わっていたので前回の記事で再入門した。 sailコマンドでコンテナの外からartisanコマンドを叩けて便利。 [clink url=\"https://ikuty.com/2021/05/16/laravel8-sail/\"] Laravel5,6あたりでSocialiteパッケージによりSNS認証を簡単に実装することができた. Laravel8+JetstreamにSocialiteを導入してSNS認証してみた. Jetstreamをインストールし,Jetstreamのrouteがある状態でSocialiteが機能するようにした. JetstreamのAuthはlaravel/uiのようにお手軽にrouteを変更できない様子. 今回はそれには触れず, 最低限の修正でJetstreamとSocialiteを両立させてみる. [arst_toc tag=\"h4\"] Jetstream導入 sailコマンド経由でインストールしていく。 composer, artisanだけでなく, npmもsailで実行できる. # jetstreamをインストールする $ ./vendor/bin/sail composer require laravel/jetstream # livewireをインストールする migrationファイルを作成する $ ./vendor/bin/sail artisan jetstream:install livewire # 作成したmigrationを実行する $ ./vendor/bin/sail artisan migrate # npm install , npm run dev $ ./vendor/bin/sail npm install $ ./vendor/bin/sail npm run dev migrationで作られたテーブル達を確認する. sailからmysqlを叩くことはできそうだが、さらに-eオプションでSQLを続けられなかった。 sail mysqlでいつものmysql clientに繋がる. sailはあくまでもユーザインターフェースなのでこれで良いか. $ ./vendor/bin/sail mysql mysql> show tables; +------------------------+ | Tables_in_example_app | +------------------------+ | failed_jobs | | migrations | | password_resets | | personal_access_tokens | | sessions | | users | +------------------------+ http://localhostを叩くと、認証機能が追加されていることを確認できる。 registerから登録してログインすると認証後URL (./dashboard) にredirectされる. profileに進むとまぁ普通に使いそうな機能が既にインプリメントされていることがわかる. routeの確認 Jetstreamをインストールした直後にJetstreamにより作られたrouteを確認してみる. いやー.. too much過ぎだろう... $ ./vendor/bin/sail artisan route:list +--------+----------+----------------------------------+---------------------------------+---------------------------------------------------------------------------------+-----------------------------------------------------------+ | Domain | Method | URI | Name | Action | Middleware | +--------+----------+----------------------------------+---------------------------------+---------------------------------------------------------------------------------+-----------------------------------------------------------+ | | GET|HEAD | / | | Closure | web | | | GET|HEAD | api/user | | Closure | api | | | | | | | AppHttpMiddlewareAuthenticate:sanctum | | | GET|HEAD | dashboard | dashboard | Closure | web | | | | | | | AppHttpMiddlewareAuthenticate:sanctum | | | | | | | IlluminateAuthMiddlewareEnsureEmailIsVerified | | | GET|HEAD | forgot-password | password.request | LaravelFortifyHttpControllersPasswordResetLinkController@create | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | POST | forgot-password | password.email | LaravelFortifyHttpControllersPasswordResetLinkController@store | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | GET|HEAD | livewire/livewire.js | | LivewireControllersLivewireJavaScriptAssets@source | | | | GET|HEAD | livewire/livewire.js.map | | LivewireControllersLivewireJavaScriptAssets@maps | | | | POST | livewire/message/{name} | livewire.message | LivewireControllersHttpConnectionHandler | web | | | GET|HEAD | livewire/preview-file/{filename} | livewire.preview-file | LivewireControllersFilePreviewHandler@handle | web | | | POST | livewire/upload-file | livewire.upload-file | LivewireControllersFileUploadHandler@handle | web | | | | | | | IlluminateRoutingMiddlewareThrottleRequests:60,1 | | | GET|HEAD | login | login | LaravelFortifyHttpControllersAuthenticatedSessionController@create | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | POST | login | | LaravelFortifyHttpControllersAuthenticatedSessionController@store | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | | | | | IlluminateRoutingMiddlewareThrottleRequests:login | | | POST | logout | logout | LaravelFortifyHttpControllersAuthenticatedSessionController@destroy | web | | | GET|HEAD | register | register | LaravelFortifyHttpControllersRegisteredUserController@create | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | POST | register | | LaravelFortifyHttpControllersRegisteredUserController@store | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | POST | reset-password | password.update | LaravelFortifyHttpControllersNewPasswordController@store | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | GET|HEAD | reset-password/{token} | password.reset | LaravelFortifyHttpControllersNewPasswordController@create | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | GET|HEAD | sanctum/csrf-cookie | | LaravelSanctumHttpControllersCsrfCookieController@show | web | | | GET|HEAD | two-factor-challenge | two-factor.login | LaravelFortifyHttpControllersTwoFactorAuthenticatedSessionController@create | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | POST | two-factor-challenge | | LaravelFortifyHttpControllersTwoFactorAuthenticatedSessionController@store | web | | | | | | | AppHttpMiddlewareRedirectIfAuthenticated:web | | | | | | | IlluminateRoutingMiddlewareThrottleRequests:two-factor | | | GET|HEAD | user/confirm-password | password.confirm | LaravelFortifyHttpControllersConfirmablePasswordController@show | web | | | | | | | AppHttpMiddlewareAuthenticate | | | POST | user/confirm-password | | LaravelFortifyHttpControllersConfirmablePasswordController@store | web | | | | | | | AppHttpMiddlewareAuthenticate | | | GET|HEAD | user/confirmed-password-status | password.confirmation | LaravelFortifyHttpControllersConfirmedPasswordStatusController@show | web | | | | | | | AppHttpMiddlewareAuthenticate | | | PUT | user/password | user-password.update | LaravelFortifyHttpControllersPasswordController@update | web | | | | | | | AppHttpMiddlewareAuthenticate | | | GET|HEAD | user/profile | profile.show | LaravelJetstreamHttpControllersLivewireUserProfileController@show | web | | | | | | | AppHttpMiddlewareAuthenticate | | | | | | | IlluminateAuthMiddlewareEnsureEmailIsVerified | | | PUT | user/profile-information | user-profile-information.update | LaravelFortifyHttpControllersProfileInformationController@update | web | | | | | | | AppHttpMiddlewareAuthenticate | | | POST | user/two-factor-authentication | two-factor.enable | LaravelFortifyHttpControllersTwoFactorAuthenticationController@store | web | | | | | | | AppHttpMiddlewareAuthenticate | | | | | | | IlluminateAuthMiddlewareRequirePassword | | | DELETE | user/two-factor-authentication | two-factor.disable | LaravelFortifyHttpControllersTwoFactorAuthenticationController@destroy | web | | | | | | | AppHttpMiddlewareAuthenticate | | | | | | | IlluminateAuthMiddlewareRequirePassword | | | GET|HEAD | user/two-factor-qr-code | two-factor.qr-code | LaravelFortifyHttpControllersTwoFactorQrCodeController@show | web | | | | | | | AppHttpMiddlewareAuthenticate | | | | | | | IlluminateAuthMiddlewareRequirePassword | | | GET|HEAD | user/two-factor-recovery-codes | two-factor.recovery-codes | LaravelFortifyHttpControllersRecoveryCodeController@index | web | | | | | | | AppHttpMiddlewareAuthenticate | | | | | | | IlluminateAuthMiddlewareRequirePassword | | | POST | user/two-factor-recovery-codes | | LaravelFortifyHttpControllersRecoveryCodeController@store | web | | | | | | | AppHttpMiddlewareAuthenticate | | | | | | | IlluminateAuthMiddlewareRequirePassword | +--------+----------+----------------------------------+---------------------------------+---------------------------------------------------------------------------------+-----------------------------------------------------------+ Socialite導入 Laravel5とか6あたりではSocialiteパッケージを導入することでSNS認証を簡単に作れた. Laravel8+Jetstreamでも同じように作れるのか試してみた. 以下の記事を参考にさせていただきました. 【Laravel】JetstreamでSNS認証(ソーシャルログイン) # Socialite インストール ./vendor/bin/sail composer require laravel/socialite # google用provider インストール ./vendor/bin/sail composer require socialiteproviders/google OAuth idとsecret を取得しておく. (id,secretの発行にはこちらを参考にさせていただきました.) Callback redirect先のURLとして http://localhost/login/google/callback を登録する. Socialite実装 .envにOAuth認証id,secret,redirectURLを書く. .env自体はhostから編集すれば良い. GOOGLE_KEY=\"*****-*******.apps.googleusercontent.com\" GOOGLE_SECRET=\"****-****\" GOOGLE_REDIRECT_URI=\"http://localhost/login/google/callback\" config/servicesに以下の設定を追加する. \'google\' => [ \'client_id\' => env(\'GOOGLE_KEY\'), \'client_secret\' => env(\'GOOGLE_SECRET\'), \'redirect\' => env(\'GOOGLE_REDIRECT_URI\'), ], Routeを追加する. Laravel7までとLaravel8ではRouteの書き方が異なる. Laravel7までは app/Providers/RouteServiceProvider.php に名前空間が定義されているため, Routeに書くコントローラの名前空間を書かなくても自動的に解決してくれた. 例えば, LoginController::class と書くと, 自動的にApp/Http/Controllers/LoginController::class と解釈された. Laravel8では, 名前空間を省略できなくなった. Route::prefix(\'login/{provider}\')->where([\'provider\'=> \'google\'])->group(function(){ Route::get(\'/\',[AppHttpControllersAuthLoginController::class, \'redirectToProvider\'])->name(\'sns_login.redirect\'); Route::get(\'/callback/\',[AppHttpControllersAuthLoginController::class, \'handleProviderCallback\'])->name(\'sns_login.callback\'); }); Socialite Providerを config/app.php のproviders に追加する /* * Socialite Providerをconfig/app.php の providers に追加する */ \'providers\' => [ ... SocialiteProvidersManagerServiceProvider::class, ... ], app/Providers/EventServiceProvider.php を以下の通り変更する. <?php namespace AppProviders; use IlluminateAuthEventsRegistered; use IlluminateAuthListenersSendEmailVerificationNotification; use IlluminateFoundationSupportProvidersEventServiceProvider as ServiceProvider; use IlluminateSupportFacadesEvent; use SocialiteProvidersManagerSocialiteWasCalled; //追加 class EventServiceProvider extends ServiceProvider { /** * The event listener mappings for the application. * * @var array */ protected $listen = [ Registered::class => [ SendEmailVerificationNotification::class, ], // 追加 SocialiteProvidersManagerSocialiteWasCalled::class => [ \'SocialiteProviders\\Google\\GoogleExtendSocialite@handle\', ], ]; /** * Register any events for your application. * * @return void */ public function boot() { // } } SNS認証によるログインを担うコントローラを自力で作成する. $ ./vendor/bin/sail artisan make:controller Auth\\LoginController Controller created successfully. 作成したコントローラの中身は以下の通り. <?php namespace AppHttpControllersAuth; use AppHttpControllersController; use AppModelsUser; use IlluminateHttpRequest; use LaravelSocialiteFacadesSocialite; use IlluminateSupportFacadesHash; use IlluminateSupportStr; class LoginController extends Controller { // メディア側へのリダイレクト public function redirectToProvider(Request $request) { $provider = $request->provider; return Socialite::driver($provider)->redirect(); } // メディア側から返されるユーザー情報 public function handleProviderCallback(Request $request) { $provider = $request->provider; $sns_user = Socialite::driver($provider)->user(); $sns_email = $sns_user->getEmail(); $sns_name = $sns_user->getName(); // 登録済ならログイン。未登録ならアカウント登録してログイン if(!is_null($sns_email)) { $user = User::firstOrCreate( // Userモデルに、レコードがあれば取得、なければ保存 [ \'email\' => $sns_email ], [ \'email\' => $sns_email, \'name\' => $sns_name, \'password\' => Hash::make(Str::random()) ]); auth()->login($user); session()->flash(\'oauth_login\', $provider.\'でログインしました。\'); return redirect(\'/\'); } return \'情報が取得できませんでした。\'; } } viewを作成する. ファイル名は app/View/auth/login.blade.php. Routeで書いた sns_login_redirect ページに遷移するリンクがあるだけ. <div> <a href=\"{{ route(\'sns_login.redirect\', \'google\') }}\">Google </div> Welcomeページのログインを修正 普通は何らかの画面が既にあってそこにSocialiteを組み込むと思うが, 今回は何もないので, とりあえずWelcomeページのログインをSocialite用に書き換えてみる. Jetstreamのrouteを変えようとしたが闇が深そうなので見なかったことにする. ちょっとJetstreamは出来が良くないのかなー.. デフォルトのWelcomeページのログインは, Jetstreamが生成する /login に合わせて作られてある. このままだと, Jetstreamが作った認証機構が動く. 例えば以下のように変更するとWelcomeページのログインをSocialiteのものに差し替えることができる. route(\'login\')をroute(\'sns_login.redirect\',\'google\')に変更した. また, registerは不要なので, registerへの遷移リンクを削除した. <body class=\"antialiased\"> <div class=\"relative flex items-top justify-center min-h-screen bg-gray-100 dark:bg-gray-900 sm:items-center py-4 sm:pt-0\"> @if (Route::has(\'sns_login.redirect\')) <div class=\"hidden fixed top-0 right-0 px-6 py-4 sm:block\"> @auth <a href=\"{{ url(\'/dashboard\') }}\" class=\"text-sm text-gray-700 underline\">Dashboard @else <a href=\"{{ route(\'sns_login.redirect\',\'google\') }}\" class=\"text-sm text-gray-700 underline\">Log in @endauth </div> @endif ... 動作確認 未ログインの状態で http://localhost を開くと, Welcome画面が表示され, Login への遷移リンクが表示される. Loginを押下すると, Googleのログイン画面に遷移する. アカウントを選択すると, http://localhost/login/google/callback にredirectがかかる. もし当サイトにアカウントがなければ,アカウントを作成する. アカウントがあれば,そのユーザでログインする. 晴れて, Googleアカウントと同じメールアドレスを持つユーザでログインした状態でダッシュボード(./dashbaord)が開く.

default eye-catch image.

SageMaker用のコードをローカルで動かす – scikit-learnの決定木でアヤメの種類を分類

SageMakerはローカルで使うことができるので、それを試してみた。 この記事を書くにあたって以下の公式の記事を参考にしています。 オンプレミス環境から Amazon SageMaker を利用する 機械学習のHelloWorld アヤメデータをschikit-learnの決定木分類器で学習して種類を予測する. 結構いろいろなところで機械学習のHelloWorldとして使われている例題を題材にしていく. sagemaker-python-sdk/scikit_learn_iris 既に SageMaker用のサンプルコードがあるので、 これをローカルで学習・推論できるように修正していく。 構成は以下の通り. 公式のブログの通り、SageMaker Notebook用に書かれた.ipynb を Local用に微修正するだけで動く。 SageMaker Notebookで動かすための.ipynb .ipynbから呼ぶschikit-learnコード SageMakerのサンプルはSageMakerのJupyterNotebookで動くように書かれているがが、 ちょっと修正するだけでローカルで動くようになる様子。(1つしか試してないけど) 前準備 学習と推論をローカルで行うが、そのために裏でDockerのコンテナが走る。 ローカルコンピュータ用にDockerをインストールしておく必要がある。 CredentialsとIAM 以下が必要。 AmazonSageMakerFullAccess 権限をもった IAM ユーザの Credential AmazonSageMakerFullAccess の IAM ロール ローカルコードからAWSリソースにアクセスするために aws configure を使って設定する。 Credentialsが書かれたcsvをダウンロードし aws configure の応答に答えていく。 $ pip install awscli --upgrade --user $ aws configure AWS Access Key ID [None]: ****************** AWS Secret Access Key [None]: ************************************ Default region name [None]: ap-northeast-1 Default output format [None]: json SageMaker PythonSDKインストール SageMaker PythonSDKをインストールする。 実行するコードに応じてSDKのバージョンを指定することができる。 $ pip install -U sagemaker >=2.15 バージョンを指定しない場合は以下の通り。 $ pip install sagemaker ローカルのJupyter Notebookでファイルを修正 scikit_learn_estimator_example_with_batch_transform.ipynb を ローカルのJupyter Notebookで修正していく。 SageMaker ローカルSessionを開始 SageMakerを想定したコードは以下。 # S3 prefix prefix = \"Scikit-iris\" import sagemaker from sagemaker import get_execution_role sagemaker_session = sagemaker.Session() # Get a SageMaker-compatible role used by this Notebook Instance. role = get_execution_role() それをローカルで動かすために以下のように修正する localSession()というセッションが用意されているのでそれを使用する。 ローカルでは get_execution_role()では ロールを取得できないので直接ロールのARNを指定する。 # S3 prefix prefix = \"Scikit-iris\" import sagemaker from sagemaker import get_execution_role # LocalSession()を使用する sagemaker_session = sagemaker.local.LocalSession() # sagemaker.Session()から変更 # Get a SageMaker-compatible role used by this Notebook Instance. # ローカルでは get_execution_role()は使えない。直接ロールのARNを指定する。 # role = get_execution_role() role = \'arn:aws:iam::(12桁のAWSアカウントID):role/(ロール名)\' 学習用データの準備 (変更なし) 学習用データが巨大であればS3にデータを準備する(と書かれている). アヤメデータは軽量なので、ローカルファイルに保存する。 import numpy as np import os from sklearn import datasets # Load Iris dataset, then join labels and features iris = datasets.load_iris() joined_iris = np.insert(iris.data, 0, iris.target, axis=1) # Create directory and write csv os.makedirs(\"./data\", exist_ok=True) np.savetxt(\"./data/iris.csv\", joined_iris, delimiter=\",\", fmt=\"%1.1f, %1.3f, %1.3f, %1.3f, %1.3f\") その後、用意したローカルデータをSageMaker Python SDKに食わせる。 WORK_DIRECTORY = \"data\" train_input = sagemaker_session.upload_data( WORK_DIRECTORY, key_prefix=\"{}/{}\".format(prefix, WORK_DIRECTORY) ) Scikit learn Estimator scikit-learnの機械学習は以下の3段構成になっている。 Estimator: 与えられたデータから学習(fit)する Transformer: 与えられたデータを変換(transform)する Predictor: 与えられたデータから結果を予測(Predict)する SageMakerは機械学習プラットフォームであって、かなり多くのライブラリや手法がサポートされている。 その中で、scikit-learnもサポートされていて、SKLearn Estimatorとして使用できる。 要は、schikit-learn のI/Fに準じたコードを SageMaker に内包することができる。 SKLearn Estimatorに scikit-learn コードを食わせると SageMakerから SKLearn インスタンスとして操作できる. 例えば、.ipynbで以下のように書く. from sagemaker.sklearn.estimator import SKLearn FRAMEWORK_VERSION = \"0.23-1\" script_path = \"scikit_learn_iris.py\" sklearn = SKLearn( entry_point=script_path, framework_version=FRAMEWORK_VERSION, instance_type=\"local\", role=role, sagemaker_session=sagemaker_session, hyperparameters={\"max_leaf_nodes\": 30}, ) SKLearnにentry_pointとして渡しているのがscikit-learnのコード本体。 内容は以下。普通の決定木分類のコードにSageMakerとのIFに関わるコードが追加されている。 実行時引数として、SM_MODEL_DIR、SM_OUTPUT_DATA_DIR、SM_CHANNEL_TRAINが渡される。 fitで学習した結果(つまり係数)をシリアライズしSM_MODEL_DIRに保存する。 model_fnでは、SM_MODEL_DIRにシリアライズされた係数をデシリアライズし、 scikit-learnの決定木分類木オブジェクトを返す。 # Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the \"License\"). # You may not use this file except in compliance with the License. # A copy of the License is located at # # http://www.apache.org/licenses/LICENSE-2.0 # # or in the \"license\" file accompanying this file. This file is distributed # on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either # express or implied. See the License for the specific language governing # permissions and limitations under the License. from __future__ import print_function import argparse import os import joblib import pandas as pd from sklearn import tree if __name__ == \"__main__\": parser = argparse.ArgumentParser() # Hyperparameters are described here. In this simple example we are just including one hyperparameter. parser.add_argument(\"--max_leaf_nodes\", type=int, default=-1) # Sagemaker specific arguments. Defaults are set in the environment variables. parser.add_argument(\"--output-data-dir\", type=str, default=os.environ[\"SM_OUTPUT_DATA_DIR\"]) parser.add_argument(\"--model-dir\", type=str, default=os.environ[\"SM_MODEL_DIR\"]) parser.add_argument(\"--train\", type=str, default=os.environ[\"SM_CHANNEL_TRAIN\"]) args = parser.parse_args() # Take the set of files and read them all into a single pandas dataframe input_files = [os.path.join(args.train, file) for file in os.listdir(args.train)] if len(input_files) == 0: raise ValueError( ( \"There are no files in {}.n\" + \"This usually indicates that the channel ({}) was incorrectly specified,n\" + \"the data specification in S3 was incorrectly specified or the role specifiedn\" + \"does not have permission to access the data.\" ).format(args.train, \"train\") ) raw_data = [pd.read_csv(file, header=None, engine=\"python\") for file in input_files] train_data = pd.concat(raw_data) # labels are in the first column train_y = train_data.iloc[:, 0] train_X = train_data.iloc[:, 1:] # Here we support a single hyperparameter, \'max_leaf_nodes\'. Note that you can add as many # as your training my require in the ArgumentParser above. max_leaf_nodes = args.max_leaf_nodes # Now use scikit-learn\'s decision tree classifier to train the model. clf = tree.DecisionTreeClassifier(max_leaf_nodes=max_leaf_nodes) clf = clf.fit(train_X, train_y) # Print the coefficients of the trained classifier, and save the coefficients joblib.dump(clf, os.path.join(args.model_dir, \"model.joblib\")) def model_fn(model_dir): \"\"\"Deserialized and return fitted model Note that this should have the same name as the serialized model in the main method \"\"\" clf = joblib.load(os.path.join(model_dir, \"model.joblib\")) return clf 学習 SageMaker(またはローカル)の.ipynb は、SKLearnインスタンスに対して fit() を実行するだけで良い。 sklearn.fit({\"train\": train_input}) 推論 なんと、推論はWebインターフェースになっている。 推論コンテナ内でnginxが動作し、PythonWebAppが wsgi(gunicorn) を介してnginxから入力/応答する。 SageMaker(またはローカル)の.ipynbからは、SKLearnインスタンスに対してdeploy()を実行する。 推論コンテナへのインターフェースとなるインスタンスが生成され、後はこのインスタンスに対してpredict()を呼ぶ。 推論用にデータを集めて predict()を実行する例。 テストデータと推論の結果を並べて表示している。 うまくいっていれば同じになるはず。 (こんな風に訓練データとテストデータを拾って良いのかはさておき...) predictor = sklearn.deploy(initial_instance_count=1, instance_type=\"local\") import itertools import pandas as pd shape = pd.read_csv(\"data/iris.csv\", header=None) a = [50 * i for i in range(3)] b = [40 + i for i in range(10)] indices = [i + j for i, j in itertools.product(a, b)] test_data = shape.iloc[indices[:-1]] test_X = test_data.iloc[:, 1:] test_y = test_data.iloc[:, 0] print(predictor.predict(test_X.values)) print(test_y.values) /invocationsというURLに対してPOSTリクエストが発行されている。 応答は以下の通り、 テストデータの説明変数と、predict()の結果得られた値が一致していそう。 hqy7i6eoyi-algo-1-vq8df | 2021-05-22 16:26:50,617 INFO - sagemaker-containers - No GPUs detected (normal if no gpus installed) [0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 2. 2. 2. 2. 2. 2. 2. 2. 2.]hqy7i6eoyi-algo-1-vq8df | 172.23.0.1 - - [22/May/2021:16:26:51 +0000] \"POST /invocations HTTP/1.1\" 200 360 \"-\" \"python-urllib3/1.26.4\" [0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 2. 2. 2. 2. 2. 2. 2. 2. 2.] まとめ SageMaker用の機械学習のHelloWorldをローカルで動かしてみた。 (かなり雑だけども), SageMakerのサンプルコードをちょっと修正するだけでローカルで動くことがわかった。

default eye-catch image.

Laravel8 sailで環境構築

とにかく進歩が早いLaravel。 セマンティックバージョニングになった6あたりから結構な速度で機能を乗せて来た感がある. 付いていくのがなかなか大変というのはある. 開けた口に無理やり食べ物を押し込んでくるような強引さの中にセンスの良さを感じ取れるので、 ちょっと付いて行ってみることにする. [arst_toc tag=\"h4\"] Laravel sail Laravel公式が用意するDocker開発環境を操作する軽量なコマンドラインインターフェース. ポイントは、コンテナの外部からコンテナ内のLaravelに対してコマンドを実行できる点. dockerコマンドをラップし、コンテナの内部で実行した結果を応答する仕組みとなっている. フルスタックフレームワークであるLaravelらしく何でも内包してしまう. composerやartisanコマンド実行のために、わざわざdockerコマンドを叩くのは辛い. sailが無いとdockerコマンドを叩きまくるか、コンテナに入って作業する必要がある. sailを使うことで、コンテナの中に入らず外からかsailコマンドを実行できる. こんな風にするとdockerの上位に来る仕組みを作れるのか、と結構感動. sailでプロジェクトを作る 既存のプロジェクトにsailを導入するパターンと、新規にプロジェクトを作成するパターンの2通りがある. 今回は新規にプロジェクトを作成していく. https://laravel.build/example-app というURLはShellScriptのコードを返す. withの後ろにインストールしたいミドエウウェアを指定する. 今回はmysqlだけ. カンマ区切りで複数指定可. $ mkdir -p ~/hoge && cd ~/hoge $ curl -s \"https://laravel.build/example-app?with=mysql\" | bash $ cd example-app && ./vendor/bin/sail up ちなみに、https://laravel.build/example-appは以下のShellScriptを返す. そのShellScriptは何をやっているかというと. laravelsail/php80-composerというイメージからコンテナを起動する. laravel newコマンドでプロジェクトを作成する. artisan sail:installコマンドを実行する. ディレクトリのOwnerを変更する. (パスワードが要求される) docker info > /dev/null 2>&1 # Ensure that Docker is running... if [ $? -ne 0 ]; then echo \"Docker is not running.\" exit 1 fi docker run --rm -v $(pwd):/opt -w /opt laravelsail/php80-composer:latest bash -c \"laravel new example-app && cd example-app && php ./artisan sail:install --with=mysql\" cd example-app CYAN=\'33[0;36m\' LIGHT_CYAN=\'33[1;36m\' WHITE=\'33[1;37m\' NC=\'33[0m\' echo \"\" if sudo -n true 2>/dev/null; then sudo chown -R $USER: . echo -e \"${WHITE}Get started with:${NC} cd example-app && ./vendor/bin/sail up\" else echo -e \"${WHITE}Please provide your password so we can make some final adjustments to your application\'s permissions.${NC}\" echo \"\" sudo chown -R $USER: . echo \"\" echo -e \"${WHITE}Thank you! We hope you build something incredible. Dive in with:${NC} cd example-app && ./vendor/bin/sail up\" fi sailでコンテナを立ち上げる 要はdocker-compose upをラップしたsail upコマンドを叩く. PHPのbundlerであるcomposerの仕様上, vendor 以下にモジュールがインストールされる. sailコマンドも ./vendor/bin/ に入っている. そこで ./vendor/bin/sail up を実行する. $ cd example-app $ ./vendor/bin/sail up dockerそのものなので, Ctrl+Cで落ちる. もちろん、./vendor/bin/sail up -d によりバックグラウンドで立ち上がる. $ ./vendor/bin/sail up -d ブラウザからhttp://localhostを開く あっさり開けた. ちなみに Dockerfile内で /usr/local/bin/start-containerを実行している. start-container内ではsupervisordによりLaravelのビルトインサーバをデーモン化している. #!/usr/bin/env bash if [ ! -z \"$WWWUSER\" ]; then usermod -u $WWWUSER sail fi if [ ! -d /.composer ]; then mkdir /.composer fi chmod -R ugo+rw /.composer if [ $# -gt 0 ];then exec gosu $WWWUSER \"$@\" else /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf fi supervisord.confは以下の通り. [supervisord] nodaemon=true user=root logfile=/var/log/supervisor/supervisord.log pidfile=/var/run/supervisord.pid [program:php] command=/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=0.0.0.0 --port=80 user=sail environment=LARAVEL_SAIL=\"1\" stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 sailでLaravelのバージョンを確認してみる 試しにコンテナの外からsailコマンドでartisan --versionを実行してみる. まるでコンテナの外からartisanコマンドを打っているような感覚. 良いと思う. $ ./vendor/bin/sail artisan --version Laravel Framework 8.41.0

default eye-catch image.

ロジスティック回帰

[mathjax] 参考書籍を読んでいく. 今回はロジスティック回帰. 未知のサンプルのクラスの所属確率を見積もることができる, という重要なことが書かれている. また重みの学習は勾配降下法のフレームワークの中で統一されていて一連の理解が進む. 一度は理解しておいた方が良い内容だと思った. パーセプトロンとADALINE 線形和(z=w^T)を考えたとき 「サンプル(x)が決定境界の上にある」 というのは(w^Tx=0). (w^T x > 0), (w^T x < 0) を決定境界の 「こちら側」 か 「向こう側」 か対応させる. パーセプトロンのアイデアは以下の通り. 線形和 (z=w^T x)を決定境界とし, (phi(z)in (-1,+1))を予測ラベルとする 予測が誤った回数, つまり真のラベルと予測ラベルの差の合計がゼロになるように(w)を学習する (phi(z))はステップ関数で連続ではないので解析的な式の変形ができない 決定境界により完全に分離できなければ学習が収束することはない 決定境界から離れている度合いを連続で凸な関数として定義し, その関数が極小となる(w)を求めていこう, というアイデアに拡張したのがADALINE. (phi(z) = z). コスト関数(J(w) = frac{1}{2} sum_i left( hat{y^{(i)} - phi ( z^{(i)})} right)^2 ) トレーニングセット全体から(J(w))の極小化を考えることが可能. (勾配降下法) トレーニングセットの一部を使って(J(w))の極小化を考え, 他のトレーニングセットで反復することも可能. (確率的勾配降下法) ロジスティック回帰 決定境界を表す線形和(z=w^T x)について(phi(z) in (mathbb{R}| 0 leq z leq +1))を考える. 線形和(z)がクラス1に分類される確率を表す(phi(z))を考える. (phi(z))をどう作るのか 線形和を(0)から(1)の実数全体に変換する関数を考える. オッズ比の対数をとったロジット関数(logit(p))を使う. ここで (p)は(x)が与えられたときにサンプルデータがクラス1に属する確率. begin{eqnarray} logit(p(y=1 | x)) &=& log frac{p}{1-p} \\ &=& w_0 x_0 + w_w x_w + cdots + w_m x_m \\ &=& sum_{i=0}^m w_i x_i \\ &=& w^T x end{eqnarray} つまり, 線形和(z = w^T x in (mathbb{R} | 0 leq z leq 1) ) (p)は未知であるということが重要. この式から(p)を予測していく. (f(p) = log frac{p}{1-p}) の逆関数が分かれば良い. begin{eqnarray} f(p) &=& log frac{p}{1-p} \\ e^{f(p)} &=& frac{p}{1-p} \\ (1-p)e^{f(p)} &=& p \\ e^{f(p)} &=& p(1+e^{f(p)}) \\ p &=& frac{e^{f(p)}}{1+e^{f(p)}} \\ p &=& frac{1}{1+e^{-f(p)}} end{eqnarray} (f(p)=z)としていたので以下のようになる. (zとpが混在していてたぶん数式としての表現は間違ってる.. ) begin{eqnarray} p &=& frac{1}{1+e^{-z}} end{eqnarray} (p)は確率なので(p in (mathbb{R}|0 leq p leq 1)). (phi(z) in (mathbb{R}|0 leq p leq 1) )とすると, begin{eqnarray} phi(z) = frac{1}{1+e^{-z}} end{eqnarray} (z-phi(z))をGeoGebraでプロットすると以下のような感じ. (z)を大きくしていくと(phi(z))は限りなく(1)に近づく. (z)を小さくしていくと(phi(z))は限りなく(0)に近づく. (phi(z)=)の出力は正事象が起こる確率であると解釈される. (phi(z)=0.8)である場合, 80%の確率で正事象が起こり,20%の確率で正事象が起こらないことを表す. トレーニングデータからパラメタを学習し, ある(phi(z))が得られたとして, 未知のサンプルのクラスの所属関係を確率で見積もることができる. 天気予報で晴れの確率70%、晴れでない確率30%のように. 予測の際に,クラスの所属関係の確率を見積もることができるのが神がかるレベルで重要!! もし(phi(z))を2値分類に当てはめるのであれば, (phi(z)=0.5)を境に上半分を(1)、下半分を(0)と予測するものとする. begin{eqnarray} hat{y} = begin{cases} 1 & phi(z) ge 0.5 \\ 0 & phi(z) lt 0.5 end{cases} end{eqnarray} それって, (z=0)を境に左半分, 右半分に割り付けるのと同じなので, begin{eqnarray} hat{y} = begin{cases} 1 & z ge 0 \\ 0 & z lt 0 end{cases} end{eqnarray} ロジスティック回帰の重みの学習 では(w)をどうやって学習するか. 式をイジるだけで面倒なだけで,これ以上は洞察得られないなと思うので, 流れだけ書いてみる. 学生時代に形態素解析っぽい何かのアルゴリズムを考えたことがあって, 形態素どうしのベターな接続を求め際に, 接続コストをコーパスから作った確率で表して, 尤度を最大化する問題として解いた記憶があって, 今更合点が行くという謎. 勉強不足で尤度最大化のアイデアがあってそうした訳ではないので, もっと勉強していたらよかったな..と. ADALINEの説明の中で, 真の値と予測値の差の2乗誤差をコスト関数として, 連続で凸な関数の極小化の問題として(w)を求めていった. ロジスティック回帰の場合, 予測は(phi(z))も連続で凸な関数なので, 同じロジックで(w)を求めることもできる. コスト関数(J(w))は同様に以下. begin{eqnarray} J(w) &=& sum_i frac{1}{2} left( phi(z^{(i)}) - y^{(i)} right)^2 end{eqnarray} (phi(z)=frac{1}{1+e^{-z}})だと, これを解析的に微分するのは不可能. トレーニングデータが(m)個あった場合に(i)番目のトレーニングデータにおける確率は以下. (x)も(w)もパラメタですよ,って書き方. begin{eqnarray} P(y|x;w)=P(y^{(i)}|x^{(i)};w) end{eqnarray} 求めたいのは, 全てのトレーニングデータについて(P(y))の積が最大になる(w). 積,つまり尤度を書き下すと以下のようになる. begin{eqnarray} L(w) &=& prod_{i=1}^m left( P(y^{(i)}) | x^{(i)};w right) \\ &=& prod_{i=1}^m left( phi(z^{(i)}) right)^{y^{(i)}} + left( 1-y^{(i)}right) log left( 1-phi(z^{(i)}) right) end{eqnarray} 尤度そのものの最大化するよりも対数をとったものを最大化する方が楽になる. (対数をとると指数の肩が定数倍になり積が和になる. 相当簡単になる. ) begin{eqnarray} l(w) &=& log L(w) \\ &=& sum_{i=1}^{n} left( y^{(i)} log( phi(z^{(i)}) ) + (1-y^{(i)}) log (1-phi(z^{(i)})) right) end{eqnarray} これを最大化するということは, これに(-1)を掛けたものを最小化するということ. そもそもコスト関数(J(w))を立てて最小化しようとしていたので, これを(J(w))と置いてしまう. begin{eqnarray} J(w) = sum_{i=1}^{n} left( -y^{(i)} log( phi(z^{(i)}) ) - (1-y^{(i)}) log (1-phi(z^{(i)})) right) end{eqnarray} ADALINEの勾配降下法で, コスト関数を最小にするように(w)を更新していった. つまり, (w := w - Delta w)という更新をしていった. ロジスティック回帰において, コスト関数を対数尤度で書き直していて, 対数尤度を最大化するように(w)を更新する. つまり, (w := w + Delta w)という更新をおこなう. 対数尤度を最大化する(w)を更新する話は, コスト関数を最小化する(w)を更新する話と同じ, なので, 結局のところ勾配降下法と同じ式となる. begin{eqnarray} Delta w_j &:=& - eta frac{partial J}{partial w_j} end{eqnarray} 尤度関数の偏導関数はシグモイド関数の偏導関数を使って以下のようになるらしい. (省略) シグモイド関数の偏導関数は以下 begin{eqnarray} frac{partial phi(z)}{partial z} &=& frac{1}{partial z} frac{1}{1-e^{-z}} \\ &=& frac{1}{1+e^{-1}} left( 1- frac{1}{1+e^{-z}} right) \\ &=& phi(z) (1-phi(z)) end{eqnarray} 対数尤度の偏導関数はシグモイド関数の偏導関数を使って以下. begin{eqnarray} frac{partial l(w)}{partial w_j} &=& left( y frac{1}{phi(z)} - (1-y) frac{1}{1-phi(z)} right) frac{partial}{partial w_j} phi(z) \\ &=& left( yfrac{1}{phi(z)} - (1-y) frac{1}{1-phi(z)} right) phi(z)(1-phi(z)) frac{partial}{partial w_j} z \\ &=& left( y(1-phi(z))-(1-y)phi(z) right) x_j \\ &=& left( y-phi(z) right) x_i end{eqnarray} コスト関数の最小化と対数尤度関数の最大化が同じ,というロジックが効いて, ADALINEの更新と全く同じになる. begin{eqnarray} w_j &:=& - eta frac{partial J}{partial w_j} \\ &=& eta sum_{i=1}^n left( y^{(i)} -phi(z^{(i)}) right) x_j^{(i)} end{eqnarray} 奇跡的..

default eye-catch image.

機械学習の分類問題と損失関数の最小化の話

[mathjax] 参考にした書籍でこの順序で誘導されていて理解しやすかったです. パーセプトロンによる一番簡単な教師あり学習を理解する ADALINEにより学習を凸で連続なコスト関数の最小化問題として捉える パーセプトロンの学習 2値のラベル((+1),(-1))が付与されているサンプルデータが与えられたとする. ラベル値が(-1)であるデータと, ラベル値が(+1)であるデータに分類できる. それらの境界が線形和で表される問題である場合, その境界を求めることができれば, 未知のデータに対してその境界の(+1)側か(-1)側かを判定できる. (m)次のサンプルデータと重みの線形和を考える. [ boldsymbol{z} = boldsymbol{w}^T boldsymbol{x} = w_1 x_1 + w_2 x_2 + cdots x_m x_m ] (z)に関するステップ関数を用意する. こうすることで (z)-(phi(z)) 空間において(z=theta)を境に線形和(z)が(-1),(+1)に分かれることを表現できる. [ varphi(z) = begin{cases} 1 & (z gt theta) \\ -1 & (z leq theta) end{cases} ] (theta)を左辺に移動し, (x_0 = 1), (w_0 = -theta) とすると, 線形和に(theta)の項を組み入れることができる. 新しい線形和(z\')が(0)になる箇所を境にステップ関数の応答値が切り替わる. [ varphi(z) = begin{cases} 1 & (z-theta gt 0) \\ -1 & (z-theta leq 0) end{cases} ] [ z\' = z - theta = w_0 x_0 + w_1 x_1 + w_2 x_2 + cdots x_m x_m ] あるパラメータ(boldsymbol{w})が存在するとして, データ(boldsymbol{x})について, (boldsymbol{w}^T boldsymbol{x}=0)を境界にステップ関数の応答値が切り替わる. 未知のデータ(x)に対して, (boldsymbol{w}^T boldsymbol{x})はラベルの予測値(+1), (-1)を応答する. 予測値(varphi(w^Tx))が正解であるサンプルデータに含まれる(y^{i})と同じとなる(w)を探したい. それを探していく. 最初,(w)に初期値を設定し, 以下の手続きによって(w)を更新していく. 上付きの添字はサンプルデータ内の順序数を表している. (y^{(i)})は(i)番目のサンプルデータ. 右下の添字は該当サンプルデータの各次元を表している. (y^{(i)_m})は(y^{(i)})の(m)番目の要素. 現在の(w)を使って予測値を計算する. (hat{y}=w^T x) (w)を更新する. (Delta w_j := eta(y^{i}-hat{y})x^{i}_j) 1個のサンプルデータで1回(w)が更新される. そもそもデータセットを綺麗に線形和が表す決定境界で分離できないような状態だと、 何度やっても予測したクラスラベルと真のクラスラベルの差が埋まらない。 あっちを立てればこっちが立たず、みたいになる。 予測したクラスラベルと真のクラスラベルを比較するこの方法だと、 (Delta w_j := eta(y^{i}-hat{y})x^{i}_j) が良い値なのか悪い値なのか、繰り返さないとわからない。 予測と真の値の比較を凸で連続な関数で表すことができれば、その関数の凸の底に向かうやり方で、 「今より良い次の値」に更新する方法を解析的に求めることができて都合が良い。 真の値と予測した値の差が「損失」とか「コスト」とか呼んで、 「コスト関数」を最小化するパラメータが良いパラメータなんだろう、という話が進んでいく。 ADALINEの学習 線形和をステップ関数に変換する部分があった。 こうして(w^T x varphi(w^T x))空間で (w^T x)が(-1),(+1)に分かれる(w)を求めようとした. [ varphi(z) = begin{cases} 1 & (z-theta gt 0) \\ -1 & (z-theta leq 0) end{cases} ] そうではなく以下の恒等式(左辺と右辺が同じ)を使い,真の値と予測した値の差の求め方を変えると, 差が凸で連続な関数で表せるようになり (解析的に微分できるようになり), 一番小さいところは? という話がしやすくなる. [ varphi(z)=z ] 最小二乗法のときやったやつで、差の2乗を足し合わると符号がキャンセルされてよくて、差を式で表せる。 [ J(w) = sum_i left( y^{(i)} - varphi(w^T x^{(i)}) right)^2 ] こういうのを誤差平方和と言うようで(frac{1}{2})倍するらしい. [ J(w) =frac{1}{2} sum_i left( y^{(i)} - varphi(w^T x^{(i)}) right)^2 ] (J(w))がなんで連続で凸なのかは知ったことではないが、 連続で凸であれば(J\'(w)=0)を解くと極小となる(w)が求まるのは高校生のときにやった話. (J(w))の接線(多次元だと接っする面)の傾きが(J\'(w))。 ただ(w)には次数があって傾きは以下(それぞれの次数毎の極限). [ nabla J(w) = frac{partial J(w)}{partial w_j} ] (w J(w))空間で(nabla J(w))は(w)における傾きなので, (w)に(-nabla J(w) cdot 1)を足すと, (J(w))が極小になる箇所に近づく. どれだけ行けば良いかわからないので(-nabla J(w) cdot eta)を足すことにする. (w)を以下の通り更新する. begin{eqnarray} w &:=& w + nabla J(w) \\ &:=& w -nabla J(w) cdot eta end{eqnarray} ちなみに(nabla J(w))は式をこねくり回すと計算できる. 最終的に更新は以下の通りとなる. [ w := w - eta sum_i left( y^{(i)} -varphi left( z^{(i)} right) right) x_j^{(i)} ] パーセプトロンの更新とそっくりな形が出てきて感動する... あっちは(varphi(z))が不連続なステップ関数でこっちは連続な関数. 式をこねくり回す際に, (varphi(z) = z)の恒等式の関係を使っていない. 何か連続な関数であればこれが成り立つ. 形式上,例えば(varphi(z))を以下(シグモイド)とすることでロジスティック回帰 (本当か? 次回確認.). [ varphi (z) = frac{1}{1+e^{-z}} ]

default eye-catch image.

Grafanaプラグインを読んでいく – Clock plugin

最も単純そうなプラグインを読んでいくシリーズ。 プラグインは Clock plugin。Panelプラグイン。配布はここ。 最も単純そうなDataSourceプラグインを読む記事は以下。 [clink url=\"https://ikuty.com/2020/11/14/grafana-code-read/\"] ダッシュボードに時計を表示できる。 ダッシュボードから設定をおこない表示に反映する機能を備えていて、 PanelプラグインのHelloWorldには良い感じ。 The Clock Panel can show the current time or a countdown and updates every second. Show the time in another office or show a countdown to an important event. 肝心のデータプロットに関する機能は無いので別途違うコンポーネントを読む。 インストール、ビルド 公式からインストールすると src が含まれない。 ソースコードをclone、buildすることにする。 初回だけgrafana-serverのrestartが必要。 # clone repository $ cd ~/ $ git clone https://github.com/grafana/clock-panel.git $ mv clock-panek /var/lib/grafana/plugins # install plugin $ yarn install $ yarn build # restart grafana-server $ sudo service grafana-server restart ディレクトリ・ファイル構成 ディレクトリ・ファイル構成は以下の通り。 clock-panel/ src/ ClockPanel.tsx ... プラグイン本体 module.ts ... プラグインのエントリポイント options.tsx plugin.json ... プラグインの設定ファイル types.ts ... TypeScript型定義 img/ ... 画像リソース clock.svg countdown1.png screenshot-clock-options.png screenshot-clocks.png screenshot-showcase.png external/ ... 外部ライブラリ moment-duration-formant.js エントリポイント ./module.ts の内容は以下の通り。 ClockPanel.tsxで定義済みのClockPanelクラスをexportしている。 options.tsxに記述したオプション画面関連のクラスを.setPanelOptions()を介して設定する。 import { PanelPlugin } from \'@grafana/data\'; import { ClockPanel } from \'./ClockPanel\'; import { ClockOptions } from \'./types\'; import { optionsBuilder } from \'./options\'; export const plugin = new PanelPlugin(ClockPanel).setNoPadding().setPanelOptions(optionsBuilder); 本体 (ClockPanel.tsx) ./ClockPanel.tsxを読んでいく。React+TypeScript。 import React, { PureComponent } from \'react\'; import { PanelProps } from \'@grafana/data\'; import { ClockOptions, ClockType, ZoneFormat, ClockMode } from \'./types\'; import { css } from \'emotion\'; // eslint-disable-next-line import moment, { Moment } from \'moment\'; import \'./external/moment-duration-format\'; interface Props extends PanelProps {} interface State { // eslint-disable-next-line now: Moment; } export function getTimeZoneNames(): string[] { return (moment as any).tz.names(); } // PureComponentクラスを派生させることでプラグイン用のパネルクラスを定義できる。 // パネルのプロパティは PanelProps型だが 当プラグイン用にProps型に拡張している。 export class ClockPanel extends PureComponent { timerID?: any; state = { now: this.getTZ(), timezone: \'\' }; //Componentのインスタンスが生成されDOMに挿入されるときに呼ばれる //DOM挿入後,1秒間隔で this.tick()の実行を開始する。 componentDidMount() { this.timerID = setInterval( () => this.tick(), 1000 // 1 second ); } //[非推奨] DOMから削除されるときに呼ばれる。古いのかな。 //this.tick()の実行を停止する。 componentWillUnmount() { clearInterval(this.timerID); } //DOM挿入後1秒間隔で呼ばれる。 //stateを更新する。 tick() { const { timezone } = this.props.options; this.setState({ now: this.getTZ(timezone) }); } //時刻フォーマットを取得する。 //時刻フォーマットはオプション設定画面で設定され props.optionsに渡される。 //渡される変数は clockType と timeSettings である。 clockTypeは 12時間/24時間のいずれか。 //12時間なら h:mm:ss A, 24時間なら HH:mm:ss。 //カスタムの場合,clockTypeがClockType.Customになる。 getTimeFormat() { const { clockType, timeSettings } = this.props.options; if (clockType === ClockType.Custom && timeSettings.customFormat) { return timeSettings.customFormat; } if (clockType === ClockType.H12) { return \'h:mm:ss A\'; } return \'HH:mm:ss\'; } // Return a new moment instnce in the selected timezone // eslint-disable-next-line getTZ(tz?: string): Moment { if (!tz) { tz = (moment as any).tz.guess(); } return (moment() as any).tz(tz); } //カウントダウン文字列を得る //設定値countdownSettings, timezone は props.options から得られる。 // getCountdownText(): string { const { now } = this.state; const { countdownSettings, timezone } = this.props.options; //カウントダウン終了時 設定された文字列 countdownSettings.endText を返す if (!countdownSettings.endCountdownTime) { return countdownSettings.endText; } //残り時間を計算。 const timeLeft = moment.duration( moment(countdownSettings.endCountdownTime) .utcOffset(this.getTZ(timezone).format(\'Z\'), true) .diff(now) ); let formattedTimeLeft = \'\'; //計算した残り時間が0以下であれば、設定された文字列 countdownSettings.endText を返す。 if (timeLeft.asSeconds() 0) { formattedTimeLeft = timeLeft.years() === 1 ? \'1 year, \' : timeLeft.years() + \' years, \'; previous = \'years\'; } // Y months (or Y month) if (timeLeft.months() > 0 || previous === \'years\') { formattedTimeLeft += timeLeft.months() === 1 ? \'1 month, \' : timeLeft.months() + \' months, \'; previous = \'months\'; } // Z days (or Z day) if (timeLeft.days() > 0 || previous === \'months\') { formattedTimeLeft += timeLeft.days() === 1 ? \'1 day, \' : timeLeft.days() + \' days, \'; previous = \'days\'; } // A hours (or A hour) if (timeLeft.hours() > 0 || previous === \'days\') { formattedTimeLeft += timeLeft.hours() === 1 ? \'1 hour, \' : timeLeft.hours() + \' hours, \'; previous = \'hours\'; } // B minutes (or B minute) if (timeLeft.minutes() > 0 || previous === \'hours\') { formattedTimeLeft += timeLeft.minutes() === 1 ? \'1 minute, \' : timeLeft.minutes() + \' minutes, \'; } // C minutes (or C minute) formattedTimeLeft += timeLeft.seconds() === 1 ? \'1 second \' : timeLeft.seconds() + \' seconds\'; return formattedTimeLeft; } //Zoneを表示するh4タグを作成して返す。Reactっぽい。 renderZone() { const { now } = this.state; const { timezoneSettings } = this.props.options; const { zoneFormat } = timezoneSettings; // ReactでCSSを書く作法。ヒアドキュメント // ロジックコードの中にHTML生成コードが混在して非常に見辛い。 const clazz = css` font-size: ${timezoneSettings.fontSize}; font-weight: ${timezoneSettings.fontWeight}; line-height: 1.4; `; let zone = this.props.options.timezone || \'\'; switch (zoneFormat) { case ZoneFormat.offsetAbbv: zone = now.format(\'Z z\'); break; case ZoneFormat.offset: zone = now.format(\'Z\'); break; case ZoneFormat.abbv: zone = now.format(\'z\'); break; default: try { zone = (this.getTZ(zone) as any)._z.name; } catch (e) { console.log(\'Error getting timezone\', e); } } return ( {zone} {zoneFormat === ZoneFormat.nameOffset && ( ({now.format(\'Z z\')}) ); } //Dateを表示するh3タグを返す。 renderDate() { const { now } = this.state; const { dateSettings } = this.props.options; const clazz = css` font-size: ${dateSettings.fontSize}; font-weight: ${dateSettings.fontWeight}; `; const disp = now.locale(dateSettings.locale || \'\').format(dateSettings.dateFormat); return ( {disp} ); } //Timeを返すh2タグを返す。 renderTime() { const { now } = this.state; const { timeSettings, mode } = this.props.options; const clazz = css` font-size: ${timeSettings.fontSize}; font-weight: ${timeSettings.fontWeight}; `; const disp = mode === ClockMode.countdown ? this.getCountdownText() : now.format(this.getTimeFormat()); return {disp}; } //React componentとしてrender()メソッドを実装する必要がある。 //CSSを整形してZone,Date,Timeを設定して返す。 render() { const { options, width, height } = this.props; const { bgColor, dateSettings, timezoneSettings } = options; const clazz = css` display: flex; align-items: center; justify-content: center; flex-direction: column; background-color: ${bgColor ?? \'\'}; text-align: center; `; return ( {dateSettings.showDate && this.renderDate()} {this.renderTime()} {timezoneSettings.showTimezone && this.renderZone()} ); } } 設定 (options.tsx) 設定画面側を読んでいく。 PanelOptionsEditorBuilder型の引数を取り、builderに対して機能実装していく。 機能実装というのは、つまり、ラジオボタンを追加したり、カスタムエディタを追加したり、など。 この実装で以下のような設定画面が表示される。(README.mdは古いので注意)。 Modeとして、時間をそのまま表示するTimeモードか、カウンドダウンモードかを二者択一で設定する。 背景色(BackgroundColor)をカラーピッカーで設定する。(ちなみにGrafanaV7では機能しない様子)。 addTimeFormat()メソッドにより、24h表示/12h表示/カスタム表示,FontSize,FontWeightの設定機能を追加する。 addTimeZone()メソッドにより,TimeZoneと表示有無の設定機能を追加する。 カウントダウンモードに設定すると、カウントダウン設定をおこなえるが, addCountdown()メソッドにより,カウントダウン設定を追加する。 決められた構文にしたがって欲しい機能を追加していくだけなので、 設定画面の実装が必要になったら必要な構文を調べて追加していくことになりそう。 import React from \'react\'; import { PanelOptionsEditorBuilder, GrafanaTheme, dateTime } from \'@grafana/data\'; import { ColorPicker, Input, Icon, stylesFactory } from \'@grafana/ui\'; import { css } from \'emotion\'; import { config } from \'@grafana/runtime\'; import { ClockOptions, ClockMode, ClockType, FontWeight, ZoneFormat } from \'./types\'; import { getTimeZoneNames } from \'./ClockPanel\'; export const optionsBuilder = (builder: PanelOptionsEditorBuilder) => { // Global options builder //ClockModeの二者択一。TimeかCountdownを選ばせる。 .addRadio({ path: \'mode\', name: \'Mode\', settings: { options: [ { value: ClockMode.time, label: \'Time\' }, { value: ClockMode.countdown, label: \'Countdown\' }, ], }, defaultValue: ClockMode.time, }) //背景色のカスタムエディタ。カラーピッカーから色を選ばせる。 .addCustomEditor({ id: \'bgColor\', path: \'bgColor\', name: \'Background Color\', editor: props => { const styles = getStyles(config.theme); let prefix: React.ReactNode = null; let suffix: React.ReactNode = null; if (props.value) { suffix = props.onChange(undefined)} />; } prefix = ( ); return ( { console.log(\'CLICK\'); }} prefix={prefix} suffix={suffix} /> ); }, defaultValue: \'\', }); // TODO: refreshSettings.syncWithDashboard addCountdown(builder); addTimeFormat(builder); addTimeZone(builder); addDateFormat(builder); }; //--------------------------------------------------------------------- // COUNTDOWN //--------------------------------------------------------------------- function addCountdown(builder: PanelOptionsEditorBuilder) { const category = [\'Countdown\']; builder .addTextInput({ category, path: \'countdownSettings.endCountdownTime\', name: \'End Time\', settings: { placeholder: \'ISO 8601 or RFC 2822 Date time\', }, defaultValue: dateTime(Date.now()) .add(6, \'h\') .format(), showIf: o => o.mode === ClockMode.countdown, }) .addTextInput({ category, path: \'countdownSettings.endText\', name: \'End Text\', defaultValue: \'00:00:00\', showIf: o => o.mode === ClockMode.countdown, }) .addTextInput({ category, path: \'countdownSettings.customFormat\', name: \'Custom format\', settings: { placeholder: \'optional\', }, defaultValue: undefined, showIf: o => o.mode === ClockMode.countdown, }); } //--------------------------------------------------------------------- // TIME FORMAT //--------------------------------------------------------------------- function addTimeFormat(builder: PanelOptionsEditorBuilder) { const category = [\'Time Format\']; builder .addRadio({ category, path: \'clockType\', name: \'Clock Type\', settings: { options: [ { value: ClockType.H24, label: \'24 Hour\' }, { value: ClockType.H12, label: \'12 Hour\' }, { value: ClockType.Custom, label: \'Custom\' }, ], }, defaultValue: ClockType.H24, }) .addTextInput({ category, path: \'timeSettings.customFormat\', name: \'Time Format\', description: \'the date formatting pattern\', settings: { placeholder: \'date format\', }, defaultValue: undefined, showIf: opts => opts.clockType === ClockType.Custom, }) .addTextInput({ category, path: \'timeSettings.fontSize\', name: \'Font size\', settings: { placeholder: \'date format\', }, defaultValue: \'12px\', }) .addRadio({ category, path: \'timeSettings.fontWeight\', name: \'Font weight\', settings: { options: [ { value: FontWeight.normal, label: \'Normal\' }, { value: FontWeight.bold, label: \'Bold\' }, ], }, defaultValue: FontWeight.normal, }); } //--------------------------------------------------------------------- // TIMEZONE //--------------------------------------------------------------------- function addTimeZone(builder: PanelOptionsEditorBuilder) { const category = [\'Timezone\']; const timezones = getTimeZoneNames().map(n => { return { label: n, value: n }; }); timezones.unshift({ label: \'Default\', value: \'\' }); builder .addSelect({ category, path: \'timezone\', name: \'Timezone\', settings: { options: timezones, }, defaultValue: \'\', }) .addBooleanSwitch({ category, path: \'timezoneSettings.showTimezone\', name: \'Show Timezone\', defaultValue: false, }) .addSelect({ category, path: \'timezoneSettings.zoneFormat\', name: \'Display Format\', settings: { options: [ { value: ZoneFormat.name, label: \'Normal\' }, { value: ZoneFormat.nameOffset, label: \'Name + Offset\' }, { value: ZoneFormat.offsetAbbv, label: \'Offset + Abbreviation\' }, { value: ZoneFormat.offset, label: \'Offset\' }, { value: ZoneFormat.abbv, label: \'Abbriviation\' }, ], }, defaultValue: ZoneFormat.offsetAbbv, showIf: s => s.timezoneSettings?.showTimezone, }) .addTextInput({ category, path: \'timezoneSettings.fontSize\', name: \'Font size\', settings: { placeholder: \'font size\', }, defaultValue: \'12px\', showIf: s => s.timezoneSettings?.showTimezone, }) .addRadio({ category, path: \'timezoneSettings.fontWeight\', name: \'Font weight\', settings: { options: [ { value: FontWeight.normal, label: \'Normal\' }, { value: FontWeight.bold, label: \'Bold\' }, ], }, defaultValue: FontWeight.normal, showIf: s => s.timezoneSettings?.showTimezone, }); } //--------------------------------------------------------------------- // DATE FORMAT //--------------------------------------------------------------------- function addDateFormat(builder: PanelOptionsEditorBuilder) { const category = [\'Date Options\']; builder .addBooleanSwitch({ category, path: \'dateSettings.showDate\', name: \'Show Date\', defaultValue: false, }) .addTextInput({ category, path: \'dateSettings.dateFormat\', name: \'Date Format\', settings: { placeholder: \'Enter date format\', }, defaultValue: \'YYYY-MM-DD\', showIf: s => s.dateSettings?.showDate, }) .addTextInput({ category, path: \'dateSettings.locale\', name: \'Locale\', settings: { placeholder: \'Enter locale: de, fr, es, ... (default: en)\', }, defaultValue: \'\', showIf: s => s.dateSettings?.showDate, }) .addTextInput({ category, path: \'dateSettings.fontSize\', name: \'Font size\', settings: { placeholder: \'date format\', }, defaultValue: \'20px\', showIf: s => s.dateSettings?.showDate, }) .addRadio({ category, path: \'dateSettings.fontWeight\', name: \'Font weight\', settings: { options: [ { value: FontWeight.normal, label: \'Normal\' }, { value: FontWeight.bold, label: \'Bold\' }, ], }, defaultValue: FontWeight.normal, showIf: s => s.dateSettings?.showDate, }); } const getStyles = stylesFactory((theme: GrafanaTheme) => { return { colorPicker: css` padding: 0 ${theme.spacing.sm}; `, inputPrefix: css` display: flex; align-items: center; `, trashIcon: css` color: ${theme.colors.textWeak}; cursor: pointer; &:hover { color: ${theme.colors.text}; } `, }; });

default eye-catch image.

Grafanaプラグインを読んでいく – simpod-json-datasource plugin

最も単純そうなDataSourceプラグインを読んでいく。 プラグインは simpod-json-datasource plugin。 配布はここ。 JSONを返す任意のURLにリクエストを投げて結果を利用できるようにする。 TypeScriptで書かれている。 The JSON Datasource executes JSON requests against arbitrary backends. JSON Datasource is built on top of the Simple JSON Datasource. It has refactored code, additional features and active development. install インストール方法は以下の通り。 初回だけgrafana-serverのrestartが必要。 # install plugin $ grafana-cli plugins install simpod-json-datasource # restart grafana-server $ sudo service grafana-server restart build ビルド方法は以下の通り。 yarn一発。 # build $ cd /var/lib/grafana/plugins/simpod-json-datasource $ yarn install $ yarn run build 設定 データソースの追加で、今回インストールした DataSource/JSON (simpod-json-datasource)を 選択すると、プラグインのコンフィグを変更できる。 要求するURL一覧 URLはBaseURL。 このプラグインはURLの下にいくつかのURLが存在することを想定している。 つまり、それぞれのURLに必要な機能を実装することでプラグインが機能する。 用語については順に解説する。 必須 Method Path Memo GET / ConfigページでStatusCheckを行うためのURL。200が返ればConfigページで「正常」となる。 POST /search 呼び出し時に「利用可能なメトリクス」を返す。 POST /query 「メトリクスに基づくデータポイント」を返す。 POST /annotations 「アノテーション」を返す。 オプショナル Method Path Memo POST /tag-keys 「ad-hocフィルタ」用のタグのキーを返す。 POST /tag-values 「ad-hocフィルタ」用のタグの値を返す。 メトリクス Grafanaにおいて、整理されていないデータの中から\"ある観点\"に従ったデータを取得したいという ユースケースをモデル化している. 例えば、サーバ群の負荷状況を監視したいというケースでは「サーバ名」毎にデータを取得したいし、 センサ群から得られるデータを監視したいというケースでは「センサID」毎にデータを取得したい. これらの観点は、Grafanaにおいて「メトリクス」または「タグ」として扱われる。 センサAからセンサZのうちセンサPとセンサQのデータのみ表示したい、という感じで使う。 ここで現れるセンサAからセンサZが「メトリクス」である。 クエリ変数のサポート (metricFindQuery) 「クエリ変数」は、「メトリクス」を格納する変数である。 データソースプラグインがクエリ変数をサポートするためには、 DataSourceApi クラスの metricFindQuery を オーバーライド する. string型のqueryパラメタを受け取り、MetricFindValue型変数の配列を返す. 要は以下の問答を定義するものである。 - 質問 : 「どんなメトリクスがありますか?」 - 回答 : 「存在するメトリクスはxxx,yyy,zzzです」 ちなみに、metricFindQueryが受け取るqueryの型は、 metricFindQueryを呼び出すUIがstring型で呼び出すからstring型なのであって、 UIを変更することで別の型に変更することができる。 以下、simpod-json-datasource の metricFindQuery の実装。 // MetricFindValue interfaceはtext,valueプロパティを持つ // { \"label\":\"upper_25\", \"value\": 1}, { \"label\":\"upper_50\", \"value\": 2} のような配列を返す. metricFindQuery(query: string, options?: any, type?: string): Promise { // Grafanaが用意する interpolation(補間)関数 // query として \"query field value\" が渡されたとする // interpolated は { \"target\": \"query field value\" }のようになる. const interpolated = { type, target: getTemplateSrv().replace(query, undefined, \'regex\'), }; return this.doRequest({ url: `${this.url}/search`, data: interpolated, method: \'POST\', }).then(this.mapToTextValue); } // /searchから返ったJSONは2パターンある // 配列 ([\"upper_25\",\"upper_50\",\"upper_75\",\"upper_90\",\"upper_95\"])か、 // map ([ { \"text\": \"upper_25\", \"value\": 1}, { \"text\": \"upper_75\", \"value\": 2} ]) // これらを MetricFindValue型に変換する mapToTextValue(result: any) { return result.data.map((d: any, i: any) => { // mapの場合 if (d && d.text && d.value) { return { text: d.text, value: d.value }; } // 配列の場合 if (isObject(d)) { return { text: d, value: i }; } return { text: d, value: d }; }); } 以下、simpod-json-datasource が メトリクスを取得する部分のUI。 FormatAs, Metric, Additional JSON Dataの各項目が選択可能で、 各々の変数がViewModelとバインドされている. コードは以下。React。 FormatAsのセレクトボックスのOnChangeでsetFormatAs()が呼ばれる。 MetricのセレクトボックスのOnChagneでsetMetric()が呼ばれる。 Additional JSON DataのonBlurでsetData()が呼ばれる。 import { QueryEditorProps, SelectableValue } from \'@grafana/data\'; import { AsyncSelect, CodeEditor, Label, Select } from \'@grafana/ui\'; import { find } from \'lodash\'; import React, { ComponentType } from \'react\'; import { DataSource } from \'./DataSource\'; import { Format } from \'./format\'; import { GenericOptions, GrafanaQuery } from \'./types\'; type Props = QueryEditorProps; const formatAsOptions = [ { label: \'Time series\', value: Format.Timeseries }, { label: \'Table\', value: Format.Table }, ]; export const QueryEditor: ComponentType = ({ datasource, onChange, onRunQuery, query }) => { const [formatAs, setFormatAs] = React.useState<selectableValue>( find(formatAsOptions, option => option.value === query.type) ?? formatAsOptions[0] ); const [metric, setMetric] = React.useState<selectableValue>(); const [data, setData] = React.useState(query.data ?? \'\'); // 第2引数は依存する値の配列 (data, formatAs, metric). // 第2引数の値が変わるたびに第1引数の関数が実行される. React.useEffect(() => { // formatAs.value が 空なら何もしない if (formatAs.value === undefined) { return; } // metric.value が 空なら何もしない if (metric?.value === undefined) { return; } // onChange(..)を実行する onChange({ ...query, data: data, target: metric.value, type: formatAs.value }); onRunQuery(); }, [data, formatAs, metric]); // Metricを表示するセレクトボックスの値に関数がバインドされている. // 関数はstring型のパラメタを1個とる. const loadMetrics = (searchQuery: string) => { // datasourceオブジェクトの metricFindQuery()を呼び出す. // 引数はsearchQuery. 戻り値はMetricFindValue型の配列(key/valueが入っている). // 例えば { \"text\": \"upper_25\", \"value\": 1}, { \"text\": \"upper_75\", \"value\": 2} return datasource.metricFindQuery(searchQuery).then( result => { // { \"text\": \"upper_25\", \"value\": 1}, { \"text\": \"upper_75\", \"value\": 2} から // { \"label\": \"upper_25\", \"value\": 1}, { \"label\": \"upper_75\", \"value\": 2} へ const metrics = result.map(value => ({ label: value.text, value: value.value })); // セレクトボックスで選択中のMetricを取得し setMetric に渡す setMetric(find(metrics, metric => metric.value === query.target)); return metrics; }, response => { throw new Error(response.statusText); } ); }; return ( <> <div className=\"gf-form-inline\"> <div className=\"gf-form\"> <select prefix=\"Format As: \" options={formatAsOptions} defaultValue={formatAs} onChange={v => { setFormatAs(v); }} /> </div> <div className=\"gf-form\"> <asyncSelect prefix=\"Metric: \" loadOptions={loadMetrics} defaultOptions placeholder=\"Select metric\" allowCustomValue value={metric} onChange={v => { setMetric(v); }} /> </div> </div> <div className=\"gf-form gf-form--alt\"> <div className=\"gf-form-label\"> <label>Additional JSON Data </div> <div className=\"gf-form\"> <codeEditor width=\"500px\" height=\"100px\" language=\"json\" showLineNumbers={true} showMiniMap={data.length > 100} value={data} onBlur={value => setData(value)} /> </div> </div> </> ); // } }; クエリ変数から値を取得 (queryの実装) 続いて、選択したメトリクスのデータ列を得るための仕組み. query()インターフェースを実装する. QueryRequest型の引数を受け取る. 引数には、どのメトリクスを使う、だとか、取得範囲だとか、様々な情報が入っている。 QueryRequest型の引数を加工した値を、/query URLにPOSTで渡す. /query URLから戻ってきた値は DataQueryResponse型と互換性があり、query()の戻り値として返る. // UIから送られてきたQueryRequest型の変数optionsを処理して /query に投げるJSONを作る。 // QueryRequest型は当プラグインがGrafanaQuery型interfaceを派生させて作った型。 query(options: QueryRequest): Promise { const request = this.processTargets(options); // 処理した結果、targets配列が空なら空を返す。 if (request.targets.length === 0) { return Promise.resolve({ data: [] }); } // JSONにadhocFiltersを追加する // @ts-ignore request.adhocFilters = getTemplateSrv().getAdhocFilters(this.name); // JSONにscopedVarsを追加する options.scopedVars = { ...this.getVariables(), ...options.scopedVars }; // /queryにJSONをPOSTで投げて応答をDataQueryResponse型で返す。 return this.doRequest({ url: `${this.url}/query`, data: request, method: \'POST\', }); } // UIから送られてきたQueryRequest型変数optionsのtargetsプロパティを加工して返す // processTargets(options: QueryRequest) { options.targets = options.targets .filter(target => { // remove placeholder targets return target.target !== undefined; }) .map(target => { if (target.data.trim() !== \'\') { // JSON様の文字列target.data をJSONオブジェクト(key-value)に変換する // reviverとして関数が指定されている // valueが文字列であった場合にvalue内に含まれる変数名を置換する // 置換ルールは cleanMatch メソッド target.data = JSON.parse(target.data, (key, value) => { if (typeof value === \'string\') { return value.replace((getTemplateSrv() as any).regex, match => this.cleanMatch(match, options)); } return value; }); } // target.targetには、変数のプレースホルダ($..)が存在する. // grafanaユーザが入力した変数がoptions.scopedVarsに届くので、 // target.target内のプレースホルダをoptions.scopedVarsで置換する。 // 置換後の書式は正規表現(regex) if (typeof target.target === \'string\') { target.target = getTemplateSrv().replace(target.target.toString(), options.scopedVars, \'regex\'); } return target; }); return options; } // cleanMatch cleanMatch(match: string, options: any) { // const replacedMatch = getTemplateSrv().replace(match, options.scopedVars, \'json\'); if ( typeof replacedMatch === \'string\' && replacedMatch[0] === \'\"\' && replacedMatch[replacedMatch.length - 1] === \'\"\' ) { return JSON.parse(replacedMatch); } return replacedMatch; } TypeScriptに明るくない場合、以下を参照。 - Typescript-array-filter - 【JavaScript】map関数を用いたおしゃれな配列処理 - TypeScript - String replace() JavaScriptのreplaceとGrafanaのreplaceが混在していて、 IDEが無いとかなり厳しい感じ。 - Interpolate variables in data source plugins - Advanced variable format options query()のパラメタについて、 Grafanaのデフォだとstring型で来るのだが、プラグインが必要に応じて好きに変更できる。 つまりquery()に値を投げる部分をプラグインが変更できるため、変更に応じて受け側も変更する。 当プラグインはDataQueryRequestインターフェースを派生させた型を用意している。 - DataQueryRequest interface getTemplateSrv()はGrafanaのユーティリティ関数。 - getTemplateSrv variable - Github getTemplateSrv - Variable syntax 現在アクティブなダッシュボード内の変数が全て得られる。 * Via the TemplateSrv consumers get access to all the available template variables * that can be used within the current active dashboard. import { getTemplateSrv } from ‘@grafana/runtime’; const templateSrv = getTemplateSrv(); const variablesProtected = templateSrv.getVariables(); const variablesStringfied = JSON.stringify( variablesProtected ); const variables = JSON.parse( variablesStringfied ); URLに投げるJSONは例えば以下の通り。 { \"app\": \"dashboard\", \"requestId\": \"Q171\", \"timezone\": \"browser\", \"panelId\": 23763571993, \"dashboardId\": 1, \"range\": { \"from\": \"2015-12-22T03:16:00.000Z\", \"to\": \"2015-12-22T03:17:00.000Z\", \"raw\": { \"from\": \"2015-12-22T03:16:00.000Z\", \"to\": \"2015-12-22T03:17:00.000Z\" } }, \"timeInfo\": \"\", \"interval\": \"50ms\", \"intervalMs\": 50, \"targets\": [ { \"refId\": \"A\", \"data\": \"\", \"target\": \"upper_50\", \"type\": \"timeseries\", \"datasource\": \"JSON-ikuty\" } ], \"maxDataPoints\": 1058, \"scopedVars\": { \"variable1\": { \"text\": [ \"upper_50\" ], \"value\": [ \"upper_50\" ] }, \"__interval\": { \"text\": \"50ms\", \"value\": \"50ms\" }, \"__interval_ms\": { \"text\": \"50\", \"value\": 50 } }, \"startTime\": 1605108668062, \"rangeRaw\": { \"from\": \"2015-12-22T03:16:00.000Z\", \"to\": \"2015-12-22T03:17:00.000Z\" }, \"adhocFilters\": [] } URLから返る値は例えば以下の通り. [ { \"target\": \"pps in\", \"datapoints\": [ [ 622, 1450754160000 ], [ 365, 1450754220000 ] ] }, { \"target\": \"pps out\", \"datapoints\": [ [ 861, 1450754160000 ], [ 767, 1450754220000 ] ] }, { \"target\": \"errors out\", \"datapoints\": [ [ 861, 1450754160000 ], [ 767, 1450754220000 ] ] }, { \"target\": \"errors in\", \"datapoints\": [ [ 861, 1450754160000 ], [ 767, 1450754220000 ] ] } ] アノテーションのサポート (annotationQueryの実装) Grafanaには、ユーザがグラフの中に注意を喚起するラベル(またはラベルの範囲)を 設定する機能がある。 ユーザが自力で書くだけでなく、プラグインがアノテーションを提供することもできる。 simpod-json-datasourceプラグインは、アノテーションを提供する機能を有する。 公式の説明はこちら。 例えば下図で薄く赤くなっている部分が プラグインによるアノテーション。 実装方法は以下。 - アノテーションサポートを有効にする - annotationQueryインターフェースを実装する - アノテーションイベントを作成する アノテーションサポートを有効にするためには、 plugin.json に以下を追加する。 { \"annotations\": true } simpod-json-datasourceプラグインのannotationQueryの実装は以下。 annotationQuery( options: AnnotationQueryRequest ): Promise { const query = getTemplateSrv().replace(options.annotation.query, {}, \'glob\'); const annotationQuery = { annotation: { query, name: options.annotation.name, datasource: options.annotation.datasource, enable: options.annotation.enable, iconColor: options.annotation.iconColor, }, range: options.range, rangeRaw: options.rangeRaw, variables: this.getVariables(), }; return this.doRequest({ url: `${this.url}/annotations`, method: \'POST\', data: annotationQuery, }).then((result: any) => { return result.data; }); } プラグインからURLにPOSTのBODYで渡ってくるデータは以下。 { \"annotation\": { \"query\": \"hogehoge\", \"name\": \"hoge\", \"datasource\": \"JSON-ikuty\", \"enable\": true, \"iconColor\": \"rgba(255, 96, 96, 1)\" }, \"range\": { \"from\": \"2015-12-22T03:16:16.275Z\", \"to\": \"2015-12-22T03:16:18.102Z\", \"raw\": { \"from\": \"2015-12-22T03:16:16.275Z\", \"to\": \"2015-12-22T03:16:18.102Z\" } }, \"rangeRaw\": { \"from\": \"2015-12-22T03:16:16.275Z\", \"to\": \"2015-12-22T03:16:18.102Z\" }, \"variables\": { \"variable1\": { \"text\": [ \"All\" ], \"value\": [ \"upper_25\", \"upper_50\", \"upper_75\", \"upper_90\", \"upper_105\" ] } } } これに対して以下の応答を返すとイメージのようになる。 以下はAnnotationEvent型と型が一致している。 isRegionがtrueの場合、timeEndを付与することでアノテーションが領域になる。 isRegionがfalseの場合、timeEndは不要。 tagsにタグ一覧を付与できる。 [ { \"text\": \"text shown in body\", \"title\": \"Annotation Title\", \"isRegion\": true, \"time\": \"1450754170000\", \"timeEnd\": \"1450754180000\", \"tags\": [ \"tag1\" ] } ]

default eye-catch image.

Redshift テーブル設計のベストプラクティス

どのようにテーブル設計するとパフォーマンスを得られるか. 公式がベストプラクティスを用意している. Redshiftのベストプラクティスが先にあってER図が後なのか、 ER図に対してベストプラクティスを適用するのか、 実際は行ったり来たりするようなイメージ. ER図とは別に何を考慮すべきなのか読み進めていく. [arst_toc tag=\"h4\"] ソートキー テーブル作成時に1つ以上の列をソートキーとして設定できる. 設定するとソートキーに準じたソート順でディスクに格納される. ソートキーに関するベストプラクティスは以下の通り. 最新のデータを得たい場合はタイムスタンプ列をソートキーにする. 1つの列に対してwhere句による範囲指定or等価指定をおこなう場合はその列をソートキーにする. ディメンションテーブルを頻繁に結合する場合は結合キーをソートキーにする. ファクトテーブルを中心にディメンションテーブルが4つある構造があるとする. ファクトテーブルにはディメンションテーブルのPKが入り関連している. また、ファクトテーブルに日付カラムがあり、常に最新のレコードが欲しいとする. ベストプラクティスによると、 各テーブルの各カラムに以下のようにソートキーを設定する. 分散スタイル クエリの実行を複数のクラスタ(コンピューティングノード、スライス)で実行するために、 それらに1)データを配信して 2)計算させて 3)結合、集計する というステップが必要になる。 最後のステップ3を達成するために、データの再び配ることが必要となる。 全体として最適となるように、1),2),3)の効率を高める必要があるが、 あらゆるデータ、条件について同じ戦略で最高の効率を得ることはできず、 設計者が戦略を指定するパラメタとなっている。 この戦略を分散スタイルと呼んでいる. 分散スタイルとして以下の3通りが用意されている. 各々だけ読むとさっぱり意味がわからないが、結局のところ再分散のコストをいかに減らすか、 というところに着目すると合点がいく. EVEN 分散 特定の列に含まれている値と関係なくラウンドロビンで複数のスライス間で行を分散させる. テーブルが結合に関与していない場合や、キー分散、ALL分散のどちらが良いかわからない場合に指定する. キー分散 キー分散のキーとは結合キーのこと. 特定の列に含まれている値に従って複数のスライスに行を分散させる.キーが同じということは「同じデータ」であり「同じデータ」達を同じスライスに分散させる意味がある. 共通の列からの一致する値が同じスライスにまとめられるよう努力する. ALL 分散 テーブル全体のコピーが全てのノードに分散される. EVEN分散、キー分散によってテーブルの一部が各ノードに配置されているときにALL分散を行うことでテーブルが関与しているあらゆる結合で全ての行が確実にコロケーションされる. 何が嬉しいのかわかりづらいが、EVEN分散やキー分散では、クエリ実行に伴って、再び必要なデータをコピーする(再分散する)必要が発生する可能性が生じる.ALL分散であればその可能性がなくなる. AUTO 分散 (デフォルト) テーブルデータのサイズに基づいて最適な分散スタイルを割り当てる. まず小さなテーブルにALL分散を設定し,テーブルが大きくなるとEVEN分散に切り替える. 分散スタイルを明示的に設定しないとAUTO分散になる. まず、ファクトテーブル関連する1つのテーブルの共通の列に基づいて分散させる. 関連するテーブルの選び方の観点は大きさで最もレコード数が大きいテーブルを選択する. 以下の構造では、ファクトテーブルとディメンションテーブル1が dim1_keyというキーを使って結合している. そこで, ファクトテーブルのdim1_key、ディメンションテーブル1のdim1_keyを分散キーとして採用する.(緑) ここまでで、dim1_keyの値が一致するレコードが同じスライスにコロケーションされる. キー分散に使うキーは1組のみ. 残りのテーブルについてはEVEN分散かALL分散を用いる. 選び方は上記の通り. テーブルのサイズが小さいのであれば、ALL分散により再分配の可能性がなくなり選びやすい. 圧縮エンコーディング 通常のRDBのように行方向の固まりを記録する場合、各列の値は型や値の傾向がまちまちであるため、 一様に圧縮しようとしても高い圧縮率を得られない. 対して、列方向の固まりを記録する場合、各列の型は同じだし値の傾向が似ていることが多いため、 高い圧縮率を得られる可能性がある. ただし、値の傾向により圧縮アルゴリズムを選択する必要がある. 公式で挙げられているアルゴリズム. 結局試してみないとわからない、というのはある. (Zstandard強すぎないか?) raw エンコード 圧縮をおこなわない. ソートキーが設定されているときはrawエンコードが設定される BOOLEAN、REAL,DOUBLE PRECISION型もraw. AZ64 エンコード Amazon 独自の圧縮エンコードアルゴリズム より小さなデータ値のグループを圧縮し、並列処理に SIMD (Single Instruction Multiple Data) 命令を使用する 数値、日付、および時刻データ型のストレージを大幅に節約する バイトディクショナリエンコード バイトディクショナリエンコード ディスク上の列値のブロックごとに、一意の値の個別のディクショナリを作成する 列に含まれる一意の値の数が制限されている場合に非常に効果的 列のデータドメインが一意の値 256 個未満である場合に最適 CHAR 列に長い文字列が含まれる場合に特に空間効率が高まる VARCHAR 列に対しては、LZO などの BYTEDICT 以外のエンコードを使用する デルタエンコード 列内の連続する値間の差を記録することにより、データを圧縮 日時列にとって非常に有用 差が小さいときに特に有用 LZO エンコード 非常に長い文字列を格納する CHAR および VARCHAR 列、特に製品説明、ユーザーコメント、JSON 文字列などの自由形式テキストに適している Mostly エンコード 列のデータ型が、格納された大部分の値で必要なサイズより大きい場合に有用. たとえば、INT2 列などの 16 ビット列を 8 ビットストレージに圧縮できる ランレングスエンコード 連続して繰り返される値を、値と連続発生数 (実行の長さ) から成るトークンに置き換える データ値が連続して繰り返されることが多いテーブルに最適 たとえば、テーブルがこれらの値でソートされている場合など Text255 および Text32k エンコード 同じ単語が頻繁に出現する VARCHAR 列を圧縮する場合に有用 Zstandard エンコード 多様なデータセット間で非常にパフォーマンスのいい高圧縮比率を提供 製品説明、ユーザーのコメント、ログ、JSON 文字列など、長さがさまざまな文字列を保存する CHAR および VARCHAR 列に対して有用 圧縮エンコーディングをテストするためには、 各アルゴリズムで差が出るように大量のデータを用意する必要がある. 公式には、テストするために大量のデータを用意することは難しいので デカルト積ででっち上げる手法が案内されている. 例えば、こんな感じにデータをでっちあげる. create table cartesian_venue( venueid smallint not null distkey sortkey, venuename varchar(100), venuecity varchar(30), venuestate char(2), venueseats integer ); insert into cartesian_venue select venueid, venuename, venuecity, venuestate, venueseats from venue, listing; このうち、venunameに対して各エンコーディングアルゴリズムを適用して格納するデータを作る. create table encodingvenue ( venueraw varchar(100) encode raw, venuebytedict varchar(100) encode bytedict, venuelzo varchar(100) encode lzo, venuerunlength varchar(100) encode runlength, venuetext255 varchar(100) encode text255, venuetext32k varchar(100) encode text32k, venuezstd varchar(100) encode zstd); insert into encodingvenue select venuename as venueraw, venuename as venuebytedict, venuename as venuelzo, venuename as venuerunlength, venuename as venuetext32k, venuename as venuetext255, venuename as venuezstd from cartesian_venue; 知りたいことは、encodingvenueの各列で実際に使われているディスク容量. 以下のようにして各列で使用される1 MBのディスク ブロック数を比較するらしい. rawが203に対してBYTEDICTが10. つまりBYTEDICTにより20:1の圧縮率を得られた例. select col, max(blocknum) from stv_blocklist b, stv_tbl_perm p where (b.tbl=p.id) and name =\'encodingvenue\' and col < 7 group by name, col order by col; col | max -----+----- 0 | 203 1 | 10 2 | 22 3 | 204 4 | 56 5 | 72 6 | 20 (7 rows) まとめ 公式のベストプラクティスを追ってみた. 面倒だけれども結構力技で出来ているなという印象. 与えられたスタースキーマからある程度決まったやり方でパラメタを選択できそう. 実データでやったら迷うことは必至w ソートキーと分散スタイルの選択は分散コストに影響する. 圧縮エンコーディングの選択はディスクストレージに影響する. 理解したら実際に試行錯誤していくしかないイメージ.