PostgreSQL ダブルクォートで括られたDB名は大文字と小文字が区別される. FATAL: database “<DBName>” does not exist .
pg_dumpコマンドを使って作られたdumpファイルをpg_restoreでリストアしようとしたところ、 DB名が無いと怒られた. dbnameという名前のDBは確かにあるはずなのに. psql: FATAL: database \"DBNAME\" does not exist 理由は、dumpファイル内で、ダブルクォートで括られた\"DB名\"に対して操作をしようとしていたから. \"DBNAME\"に対する操作は全て大文字の\"DBNAME\"に対する操作を表していて、 \"dbname\"と\"DBNAME\"は区別される。 CREATE DATABASE \"DBNAME\" OWNER = postgres TEMPLATE = template0 ENCODING = \'UTF8\' LC_COLLATE = \'C\' LC_CTYPE = \'C\'; 識別子を小文字で統一できるならそうした方が良い. 既に何かがあり出来ない状況で出くわしたなら、大文字と小文字を意識する必要がある. 以下PostgreSQLの公式ドキュメント. PostgreSQLにおいて、識別子をダブルクォートで括ると、大文字小文字が区別されるようになる. 例えば、以下のようにCREATE DATABASEを叩くと、全て大文字の DBNAME というDBが作られる. PostgreSQL 12.4文書/ 4.1.1. 識別子とキーワード
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 .. できた..
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)が開く.
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のサンプルコードをちょっと修正するだけでローカルで動くことがわかった。
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
分散と標準偏差を計算しやすくする
[mathjax] 分散と標準偏差を計算しやすく変形できる。 いちいち偏差(x_i-bar{x})を計算しておかなくても、2乗和(x_i^2)と平均(bar{x})がわかっていればOK。 begin{eqnarray} s^2 &=& frac{1}{n} sum_{i=1}^n (x_i - bar{x})^2 \\ &=& frac{1}{n} sum_{i=1}^n ( x_i^2 -2 x_i bar{x} + bar{x}^2 ) \\ &=& frac{1}{n} ( sum_{i=1}^n x_i^2 -2 bar{x} sum_{i=1}^n x_i + bar{x}^2 sum_{i=1}^n 1) \\ &=& frac{1}{n} ( sum_{i=1}^n x_i^2 -2 n bar{x}^2 + nbar{x}^2 ) \\ &=& frac{1}{n} ( sum_{i=1}^n x_i^2 - nbar{x}^2 ) \\ &=& frac{1}{n} sum_{i=1}^n x_i^2 - bar{x}^2 \\ s &=& sqrt{frac{1}{n} sum_{i=1}^n x_i^2 - bar{x}^2 } end{eqnarray} 以下みたい使える。平均と標準偏差と2乗和の関係。 begin{eqnarray} sum_{i=1}^n (x_i - bar{x})^2 &=& sum_{i=1}^n x_i^2 - nbar{x}^2 \\ ns^2 &=& sum_{i=1}^n x_i^2 - nbar{x}^2 \\ sum_{i=1}^n x_i^2 &=& n(s^2 + bar{x} ) end{eqnarray}
ロジスティック回帰
[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} 奇跡的..
機械学習の分類問題と損失関数の最小化の話
[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}} ]
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\')}) > )} </h4> ); } //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}; } `, }; });
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\" ] } ]