2019年2月28日木曜日

Cloud Storage へのアクセス元 IP を調べる方法

概要

Google Cloud Storage はデフォルトではアクセスログは記録していません
バケットのロギング機能を有効にすると CSV 形式でアクセスログを特定のバケットに残すことができます
今回はロギングを有効にし生成されたログファイルをパースしてバケットへのアクセス IP を集計してみました

環境

  • macOS 10.14.3
  • Ruby 2.5.1p57
    • google-cloud-storage 1.17.0
  • Google Cloud Storage (2019/02/27 時点)

バケットのロギングを ON にする

コンソールからだとできないようなので gsutil コマンドを使います
ログを格納するバケットを作成し、ロギングするバケットのフラグを ON にするだけです

まずログを格納するバケットを作成しましょう
バケットへのログの書き込みは GCP の内部で管理されている cloud-storage-analytics というメンバになります
バケットに cloud-storage-analytics が書き込み可能な権限を付与しましょう

  • gsutil mb -c regional -l us-central1 gs://log-bucket/
  • gsutil acl ch -g cloud-storage-analytics@google.com:W gs://log-bucket/

あとはロギングしたいバケットのフラグをオンにします
その際に先程作成したログを格納するためのバケットを指定します

  • gsutil logging set on -b gs://log-bucket gs://bucket

設定できているかは get で行います

  • gsutil logging get gs://log-bucket

これでログがバケット内に格納されはじめます

サービスアカウント作成

今回は Ruby スクリプトを使うのでそれ用のサービスアカウントを作成しましょう
すでにサービスアカウントがある場合はそれを流量しても OK です
また今回の場合バケットに対象のサービスアカウントがアクセスできる必要があるので ACL を設定しましょう

  • gsutil acl get gs://log-bucket/
  • gsutil acl ch -u ruby-script-sa@project-123456.iam.gserviceaccount.com:R gs://log-bucket
  • gsutil acl ch -u ruby-script-sa@project-123456.iam.gserviceaccount.com:R gs://log-bucket/*

もしくはバケットポリシーオンリーを使うとバケットの権限を変更するだけでそのバケット配下のオブジェクトも同じ権限にすることができます

Cloud Storage で該当のバケットを選択し権限の設定メニューを開きます
そして「バケットポリシーのみでアクセス制御を簡素化する」を有効にします

追加したサービスアカウントを「ストレージのレガシー バケット読み取り」で追加すれば配下のオブジェクトも読み取ることができるようになります

また IAM 側でも「環境ユーザと Storage Object の閲覧者」の役割を追加してください

なおバケットポリシーオンリーを有効にすると gsutil acl get などは使えなくなるのでご注意ください

Ruby のスクリプトでログを取得する

ログは CSV 形式なので手動でダウンロードして適当に集計しても OK です
ただ、ログファイルが複数に分割されているのでスクリプトで一括ダウンロードして集計します

require 'google/cloud/storage'
require 'csv'

storage = Google::Cloud::Storage.new(
  project_id: "project-123456",
  credentials: "./project-123456.json"
)

b = storage.bucket 'log-bucket'
byte = 0
ret = b.files.each_with_object(Hash.new(0)) { |f, acc|
  f.download "./#{f.name}"
  csv = CSV.read("./#{f.name}", headers: true)
  if f.name.include?('_storage_')
    byte = csv['storage_byte_hours']
  else
    csv.each { |r|
      acc[r['c_ip']] += 1
    }
  end
}
puts byte
pp ret.sort { |(k1, v1), (k2, v2)| v2 <=> v1 }

ちょっと解説

ログファイルは一度ダウンロードしなければ使えません
ファイルは CSV 形式になっていてバケットの使用量をロギングしているファイルが 1 つと複数の分割されたアクセスログがあります

今回ほしい IP アドレスの情報は「c_ip」というカラムで管理されているので CSV から読み込んで取得します
今回は一応使用量をロギングしているファイルからも storage_byte_hours を読み込んでいますが不要であれば削除してください

バケットおよびオブジェクトへの権限がうまく付与されていない場合は Invalid request (Google::Cloud::PermissionDeniedError) になるのでその場合は ACL かバケットポリシーオンリーのどちらかを使ってログファイルにアクセスできるようにしましょう

最後に

バケットへのアクセスログを解析して IP の数をカウントしてみました
ポイントはログファイルが CSV なのと複数のファイルに分割されて保存させる点かなと思います

あとは Cloud Storage を使う上で避けては通れない ACL 周りかなと思います
最近だとバケットポリシーオンリーを使って IAM の機能で管理するのが主流になっているぽいです
ACL はオブジェクト単位で管理する必要があるので確かに面倒なイメージはあります
バケット配下のオブジェクトでそれぞれで権限を設定するケースって確かに少ない気がするのでバケットポリシーオンリーが主流になっているだと思います

参考サイト

2019年2月27日水曜日

GCE のファイアウォールのログからアクセス元の IP アドレスをカウントする

概要

GCE のファイアウォールはデフォルトでロギングがオフになっています
これをオンにすることでファイアウォールのアクセスログが Stackdriver に保存されます
今回は Stackdriver に保存されたログを API で取得しアクセス元の IP アドレスの数をカウントしてみました

環境

  • Ruby 2.5.1p57
    • google-cloud-logging 1.6.2

ファイアウォールのロギングをオンにする

GCE に適用されているファイアウォールのロギングをオンにしましょう

画像は default-allow-https ですが好きなファイアウォールのロギングをオンにしてください
ちなみに view を押すと Stackdriver に溜まったログをコンソールで確認できます

サービスアカウント作成

今回は Ruby のスクリプトから Stackdriver の API をコールします
すでにサービスアカウント+ API コール用の鍵があればそれを流用して OK です
新規で作成する場合はロールの設定の際に Stackdriver logging にアクセスできるロールを設定しましょう

コード

準備が揃ったらコーディングします
まずはライブラリをインストールしましょう

  • bundle init
  • vim Gemfile
gem "google-cloud-logging"
  • bundle install --path vendor

スクリプトです
全体は以下の通りです
あとで解説しています

require 'google/cloud/logging'

Google::Cloud.configure do |config|
  config.project_id = "project-123456"
  config.keyfile = "./project-123456.json"
end

logging = Google::Cloud::Logging.new
filter = {
  :filter => 'logName:(compute.googleapis.com%2Ffirewall) AND jsonPayload.rule_details.reference:("network:default/firewall:default-allow-https")'
}
entries = logging.entries filter

# puts entries.all.to_a.size
# entries.all.each { |e| puts e.payload.to_json }
ret = entries.all.each_with_object(Hash.new(0)) { |e, acc| acc[e.payload['connection']['src_ip']] += 1 }
puts ret

解説

まずは Google::Cloud.configure で初期化します
プロジェクト ID と作成したサービスアカウントの JSON 鍵ファイルのパスを設定しましょう

ポイントはログを取得する際の logging.entries filter になります
filter を指定することで対象のファイアウォールのログだけを取得しています
Stackdriver にはログを検索するためのクエリがあります
基本はブラウザや gcloud コマンドでフィルタするときと同じ内容でフィルタすることができます
今回であれば 'logName:(compute.googleapis.com%2Ffirewall) AND jsonPayload.rule_details.reference:("network:default/firewall:default-allow-https")' になります
条件は 2 つあり logName でログの名前を絞り込みかつ対象のファイアウォールを rule_details.reference で絞り込んでいます
後者のファイアウォール名を変更すれば別のファイアウォールのログを引っ張ってくることができます

取得したログのエントリは最新から 50 件ずつ取得されるようです
クエリを使えば古いログから取得することもできると思います
nextPageToken がありそれを使って次のログを取得するのですがライブラリを使えば all を呼び出すだけで OK です
エントリ (Google::Cloud::Logging::Entry) の基本的な構図はここが参考になると思います

each で回した際の payload オブジェクトは Google::Protobuf::Struct クラスのオブジェクトです
Stackdriver のログは JSON の文字列か Protocol Buffer になります
GCP 内からのロギングは自動的に Protocol Buffer に変換されて保存されるようです
payload オブジェクトを参照する場合はハッシュで値を取得すれば OK です
ハッシュの中身がわからない場合は to_json で一度中を見てもいいと思います
ちょっと特殊な JSON ですが参照する際は普通の JSON のように参照することができます

実行結果

  • bundle exec ruby app.rb

で実行しましょう
今回は IP のカウンタ結果のハッシュを表示しているだけです

最後に

GCE へのアクセス元 IP を Stackdriver に溜め API で集計してみました
Stackdriver にログを溜めるのと API をコールするのは有料操作ですが容量は 50GB まで API コールは 100 万回まで無料なので検証レベルであれば料金がかかることはないかなと思います
GCE へのアクセスが多い場合は溜まるログの量も大量になるのでその場合は容量に注意が必要です
ロギングする際にルールを決められるのである IP からのログはロギングしないなどの工夫が必要になるかもしれません

参考サイト

2019年2月25日月曜日

jwilder/nginx-proxy を使って docker のシングルホストでゼロダウンデプロイする

概要

かなり有名な手法ですが jwilder/nginx-proxy を使ってコンテナのゼロダウンデプロイを試してみました
nginx-proxy は VIRTUAL_HOST を使ってコンテナのロードバランスをしてくれるコンテナです

環境

  • macOS 10.14.3
  • docker 18.09.2

アプリ作成

なんでも OK です
今回はホスト名を返すアプリを Ruby で作成しました
docker で動かせるように Dockerfile も作成します

  • bundle init
  • vim Gemfile
gem "sinatra"
  • vim app.rb
require 'sinatra'

get '/' do
  `hostname`.strip
end
  • vim Dockerfile
FROM ruby

ADD . /home
WORKDIR /home
RUN bundle install --path vendor
EXPOSE 4567

CMD ["bundle", "exec", "ruby", "app.rb", "-o", "0.0.0.0"]

こんな感じです
テストするのであれば以下のような感じで実行してホスト名が取得できれば OK です

  • docker build -t app .
  • docker run --rm -p 4567:4567 app
  • curl localhost:4567

docker-compose に組み込む

作成したアプリを jwilder/nginx-proxy と連携するために docker-compose.yml を作成しましょう

  • vim docker-compose.yml
version: '2'
services:
  proxy:
    image: jwilder/nginx-proxy
    restart: always
    ports:
      - "14567:80"
    volumes:
      - "/var/run/docker.sock:/tmp/docker.sock:ro"
  app:
    build: .
    restart: always
    ports:
      - "4567"
    environment:
      - VIRTUAL_HOST=localhost
      - VIRTUAL_PORT=4567
  app_a:
    extends:
      service: app

appapp_a 2 つアプリを作成します
このアプリを nginx-proxy 配下にぶら下げます
3 つ 4 つ増やしても問題ないです
ポイントは VIRTUAL_HOST でバランシングしている点です

nginx-proxy はもしアプリが死んでいる場合、そちらにはリクエストを送信しません
なのでこれで appapp_a と交互に入れ替えることでゼロダウンタイムなデプロイを実現します

テスト

では実際に試してみます
まずは docker-compose でコンテナを立ち上げます
nginx-proxy にアクセスしてアプリにバランシングされているか確認しましょう
バランシング方式は単純なラウンドロビンっぽいです

  • docker-compose up -d
  • curl localhost:14567

ここからが本番です
まずは app_a を停止してみましょう

  • docker-compose stop app_a

これでアクセスしてもちゃんとエラーにならずにレスポンスが返り続けます
app_a コンテナを再作成してみましょう

  • docker-compose up -d --force-recreate app_a

再作成後しばらくするとリクエストが新しい app_a にも振られるようになると思います
あとは同じように app に対しても行えば OK です

今回は --force-recreate でコンテナを再作成しただけですがコードが変更されておりイメージのビルドも必要な場合は以下のような感じでイメージの再ビルドもしましょう

  • git pull
  • docker-compose rm app_a
  • docker-compose build app_a
  • docker-compose up -d app_a

レジストリに登録しておき pull しても OK だと思います

Tips

例えば Let's Encrypt で取得した証明書を使って SSL を有効にするには以下のように変更します

まず docker-compose.yml でアプリの VIRTUAL_HOST の部分をちゃんとしたドメインに変更します

environment:
  - VIRTUAL_HOST=hoge.fuga.com
  - VIRTUAL_PORT=9292

そして proxy 側の定義の部分に証明書をマウントする定義を記載します
443 を LISTEN しましょう
そして証明書をコンテナの /etc/nginx/certs にマウントします
この時、鍵 (.key) と証明書 (.crt) の名前は VIRTUAL_HOST で指定したドメイン名にします

ports:
  - "80:80"
  - "443:443"
volumes:
  - "/var/run/docker.sock:/tmp/docker.sock:ro"
  - "./keys/privkey.pem:/etc/nginx/certs/hoge.fuga.com.key"
  - "./keys/fullchain.pem:/etc/nginx/certs/hoge.fuga.com.crt"

すると SSL で受け付けるための conf ファイルを自動で作成してくれます
なお SSL を有効にすると http 側のアクセスはすべて https にリダイレクトするようになっています

最後に

nginx-proxy を使ってコンテナをバランシングしてダウンタイム無しでコンテナをデプロイする方法を紹介しました
実際はこれ以外に証明書やコンテナ間のセッション情報の共有やデータベースなどが絡むのでアプリ側の改修も必要になるかもしれません

過去に haproxy を使ってロードバランシングした記事を紹介したのですがこれを使っても同じようなことができると思います

参考サイト

2019年2月24日日曜日

Google Cloud PubSub を使って Cloud Billing のアラートを Slack に通知する

概要

Cloud Billing は GCP の料金や請求情報を取得できる API です
直接料金を取得する方法はないのですがアラート機能があり特定の料金に達した場合に Cloud PubSub にメッセージを送信することができます
今回はその仕組みを使ってメッセージを受信した際に Slack にメッセージを送信してみたいと思います

環境

  • macOS 10.14.3
  • Ruby ruby 2.5.1p57 (2.6 だと cannot load such file -- google/protobuf_c (LoadError) になり動作しないっぽい)
  • Google Cloud Billing (2019/02/21 時点)
  • Google Cloud PubSub (2019/02/21 時点)

サービスアカウントの作成

Cloud PubSub API をコールするためにサービスアカウントを作成しましょう
今回は Ruby の Subscriber を用意するのでプログラムから認証情報を使えるように JSON のキーファイルも作成します

「サービスアカウント作成」を選択します

好きなサービスアカウント名を設定しましょう

権限は「PubSub 管理者」にしました
今回は Subscribe しかしないので Subscribe のみの権限でも OK です

役割は Google アカウントを指定しました
ここも好きな役割を指定する感じで良いです
ポイントは「キー」です
キーを作成する際に json or p12 が選択できますが今回は Ruby から使うので json を選択してください
作成すると同時に認証用の json ファイルもダウンロードできるので大切に保管しましょう

Cloud PubSub にトピックを作成する

次にトピックとサブスクリプションを作成します

まずはトピックです
好きなトピック名を設定してください
今回は「billing」にしました

次にサブスクリプションです
サブスクリプションはトピック内に複数作成することができます
配信タイプは pull を選択しましょう
受信時間はデフォルトの 10 秒でいいと思います
サーバにメッセージを保存できる期間もデフォルトのままでいいでしょう
これで「作成」を選択します

Google Cloud PubSub は基本的にトピックとサブスクリプションを指定してメッセージを受信します

とりあえず Subscriber の作成

作成したトピックとサブスクリプションを指定してメッセージを受信する Subscriber を作成しましょう
公式が PubSub 用のライブラリを公開してくれているので素直にそれを使います

  • bundle init
  • vim Gemfile
gem "google-cloud-pubsub"
  • bundle install --path vendor
  • vim app.rb
require 'google/cloud/pubsub'

pubsub = Google::Cloud::PubSub.new(
  project_id: 'project-123456',
  credentials: './project-123456-auth.json'
)
topic = pubsub.topic 'billing'
sub = pubsub.subscription 'slack'
subscriber = sub.listen do |msg|
  msg.acknowledge!
  puts msg.data
end
subscriber.start
sleep

とりあえずメッセージを受け取って表示するだけの subscriber です
最後に sleep を入れることでプロセスを常駐させます
あとでこれに Slack に通知する機能を付けます

  • bundle exec ruby app.rb

で起動するので PubSub のコンソールからメッセージを送信していましょう
ちゃんとメッセージが受信できれば OK です

Cloud Billing に予算のアラート設定する

「お支払い」->「予算とアラート」から作成できます
3 つの閾値を設定することができるのでお好きな閾値を設定してください

ポイントはアラートの設定の一番下にある「通知の管理」の部分です
先程作成したトピックを指定することができるので指定しましょう

Slack に通知する機能を追加する

先程の Subscriber にメッセージを受信したら Slack に通知する機能を付けましょう
Incomming Webhook URL が必要になるので Slack のインテグレーションから作成なり既存の URL を取得しておいてください

require 'google/cloud/pubsub'
require 'net/https'
require 'json'

pubsub = Google::Cloud::PubSub.new(
  project_id: 'project-123456',
  credentials: './project-123456-auth.json'
)
topic = pubsub.topic 'billing'
sub = pubsub.subscription 'slack'
subscriber = sub.listen do |msg|
  msg.acknowledge!
  url = 'https://hooks.slack.com/services/xxxxxxxxxx/yyyyyyyy/zzzzzzzzzzzzzzzzzzzzzzz'
  uri = URI.parse url
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true
  req = Net::HTTP::Post.new uri.request_uri
  req.content_type = 'application/json'
  req.body = {
    'username' => 'Cloud Billing Alert',
    'text' => "Alert Message: #{msg.data}",
    'icon_url' => 'https://slack.global.ssl.fastly.net/9fa2/img/services/hubot_128.png'
  }.to_json
  res = http.request req
end
puts 'start'
subscriber.start
sleep

url の部分は取得した Incomming Webhook URL を設定してください
class とかにしてないのでぐちゃっとしていますが net/https を使って POST リクエストを送信しているだけです
Incomming Webhook の payload に関してはこの辺りを参考にしてください

動作確認

Subscriber を起動しましょう
どうやら Cloud Billing のアラートを作成して「通知の管理」を有効にするとテスト用のメッセージを送信してくれているようです

おそらく Subscriber を起動すると以下のようにメッセージが飛んでくると思います

P.S
あとでわかったのですが 30 分おきに Cloud Billing が Publish しているようです
なのでこのままだと 30 分おきに通知が来てしまうので、costAmount の値をチェックして前回より大きくなっていたら通知するなどの工夫が必要そうです

あとはアイコンやメッセージを好きなように変更すれば OK だと思います
テストだけしたいので同じ JSON のメッセージをコンソールから Publish すれば OK です

{
 "budgetDisplayName": "base",
 "costAmount": 0.16,
 "costIntervalStart": "2019-02-01T08:00:00Z",
 "budgetAmount": 5.0,
 "budgetAmountType": "SPECIFIED_AMOUNT",
 "currencyCode": "USD"
}

最後に

Google Cloud PubSub と Cloud Billing を使って予算のアラートが閾値に達したときに Slack に通知する Subscriber を作成してみました
かなり簡単にできると思います

本当は毎日決められた時間に今月の使用料金を通知したかったのですが、Billing API に直接料金を取得できそうな API がなかったのでアラート機能を使いました
直接料金を確認したいのであればコンソールを見るしかないかなと思います

参考サイト

2019年2月23日土曜日

gcloud コマンドを複数のアカウントで切り替える方法

概要

gcloud コマンドはマルチテナント機能を持っています
複数のアカウントで複数のプロジェクトを操作することができます
configurations という機能があるのでこれを使います

環境

  • macOS 10.14.3
  • gcloud 234.0.0

新規 configurations 作成

  • gcloud config configurations create hawksnowlog
Created [hawksnowlog].
Activated [hawksnowlog].

自動的に切り替わるようです

  • gcloud config configurations list

で一覧を確認できます

NAME         IS_ACTIVE  ACCOUNT                  PROJECT              DEFAULT_ZONE  DEFAULT_REGION
default      False      username1@gmail.com  project-123456
hawksnowlog  True

新規 configurations 設定

  • gcloud auth login

でブラウザが開くので対象のアカウントでログインしましょう
あとはプロジェクトの設定をすれば OK かなと思います

  • gcloud config set project product-234567

デフォルトリージョンなど必要な設定があれば追加で実施してください

切り替える

  • gcloud config configurations activate default

default の configurations に切り替えることができます

最後に

gsutil コマンドなども同じ設定を参照しているので configurations を切り替えるだけで使えるようになります

2019年2月22日金曜日

GAE + golang 入門

概要

過去に Ruby で GAE に入門しました
このときは Flexible Environment という新しい環境を試しました
今回は Standard Environment という昔からあるランタイムを指定する環境を使ってみたいと思います
ちなみに Always free の範囲はこの Standard Environment になります
gcloud コマンドや GCP のサインアップなどは事前に済ませておいてください

環境

  • macOS 10.14.3
  • gcloud 234.0

app-engine-go のインストール

  • gcloud components install app-engine-go
  • ln -s /usr/local/Caskroom/google-cloud-sdk/latest/google-cloud-sdk/bin/dev_appserver.py /usr/local/bin/dev_appserver.py

必要なのは下の Python スクリプトです
これを使ってローカルでテストすることができます

また $GOPATH/src/google.golang.org/appengine というライブラリがあることも確認しましょう

とりあえず Hello World してみる

GAE で動作するサンプルアプリがあるのでそれを取得してサクっとデプロイしてみます

  • go get -u -d github.com/GoogleCloudPlatform/golang-samples/appengine/helloworld/...
  • cd $GOPATH/src/github.com/GoogleCloudPlatform/golang-samples/appengine/helloworld/

まずはローカルでチェックします

  • dev_appserver.py app.yaml

localhost:8080 にアクセスして Hello World が表示されることを確認します
ローカルで動作することがわかったら GAE にデプロイしてみましょう

  • gcloud app deploy
Initializing App Engine resources...done.                                                                                      
Services to deploy:

descriptor:      [/Users/hawksnowlog/go/src/github.com/GoogleCloudPlatform/golang-samples/appengine/helloworld/app.yaml]
source:          [/Users/hawksnowlog/go/src/github.com/GoogleCloudPlatform/golang-samples/appengine/helloworld]
target project:  [hawksnowlog-123456]
target service:  [default]
target version:  [20190219t153345]
target url:      [https://hawksnowlog-123456.appspot.com]


Do you want to continue (Y/n)?

ちゃんと確認画面がでます
問題なければ Y でデプロイしましょう

Web ページを配置する

このままだと何もないのでコンテンツでも配置してみます
まず index.html を作成します

  • touch index.html
  • vim index.html
<html>
  <head>
    <title>Image Contents</title>
  </head>
  <body>
    <img src="{{.Src}}">
  </body>
</html>

画像を表示するだけの HTML です
画像の URL の部分を変数にしておりこの変数を go アプリ側で設定します

作成した index.html を描画するための go アプリを作成します
コード全体は以下の通りです

  • vim main.go
package main

import (
    "html/template"
    "math/rand"
    "net/http"
    "time"

    "google.golang.org/appengine"
)

var (
    index = template.Must(template.ParseFiles("index.html"))
)

type params struct {
    Src string
}

func main() {
    http.HandleFunc("/", handle)
    appengine.Main()
}

func handle(w http.ResponseWriter, r *http.Request) {
    params := params{}
    images := [3]string{
        "https://blog.golang.org/gopher/header.jpg",
        "https://blog.golang.org/gopher/wfmu.jpg",
        "https://blog.golang.org/gopher/glenda.png",
    }
    rand.Seed(time.Now().Unix())
    params.Src = images[rand.Intn(len(images))]
    index.Execute(w, params)
}

少し解説

まずテンプレートファイルを読み込みます
"html/template" が必要になるので import しておきましょう

var (
    index = template.Must(template.ParseFiles("index.html"))
)

このテンプレートファイルを Execute することで HTML を描画できます
Execute はハンドラメソッド内で行っています
テンプレートを描画する際にパラメータを渡すことでテンプレート内の変数を展開することができます
テンプレート内で使用している変数を持つ構造体を宣言します

type params struct {
    Src string
}

ハンドラ内でこの構造体を生成し変数に画像の URL を設定しています
せっかくなので画像は 3 つのうちからランダムで取得するようにしています
ランダムの部分は調べればいろいろ出ると思います
今回は "math/rand" を使った方式で配列からランダムで取得しています

デプロイ

  • dev_appserver.py app.yaml

で確認したら

  • gcloud app deploy

で GAE にデプロイしましょう
アクセスするとローカルで動かしたように画像がランダムに表示されるのが確認できると思います

おまけ: CSS も当ててみる

当然 Web ページであれば CSS も必要になります

  • vim app.yaml
runtime: go
api_version: go1

handlers:
- url: /static
  static_dir: static
- url: /.*
  script: _go_app

静的コンテンツを配信するハンドラを 1 つ追加します
ポイントは先に /static のハンドラを定義するところです
こうしないと /.* が先に評価されてしまいうまく CSS を配信できません

  • mkdir static
  • touch static/style.css
  • vim static/style.css
.image {
    width: 100%;
    text-align: center;
}

あとは index.html を修正します
スタイルシートを参照するように変更します

<html>
  <head>
    <title>Image Contents</title>
    <link rel="stylesheet" type="text/css" href="/static/style.css">
  </head>
  <body>
    <div class="image">
      <img src="{{.Src}}">
    </div>
  </body>
</html>

これだけです
あとは同じようにデプロイすればスタイルシートが適用されたページが表示されます

おまけ: その他 Tips

ロギングや httpClient を使う場合には ctx := appengine.NewContext(r) を使う
パッケージは google.golang.org/appengine

普通の httpClient は使えないので urlfetch.Client(ctx) を使う
パッケージは google.golang.org/appengine/urlfetch

favicon.ico を使いたい場合は app.yaml に以下を追加する
また static/favicon.ico を配置する

- url: /favicon\.ico
  static_files: static/favicon.ico
  upload: static/favicon\.ico

http をすべて https にリダイレクトするには secure: always を使う

- url: /.*
  script: _go_app
  secure: always

最後に

GAE の Standard Environment に golang で入門しました
GAE で動かすためのライブラリが必要なので既存のアプリだと変更する点は多そうです
開発するためのツールは揃っているので導入障壁は低いと思います

このあとはデータを保存したりするチュートリアルもあるので、そのまま進めれば最低限必要な知識は揃うと思います

参考サイト

2019年2月21日木曜日

gsutil を使ってファイルを一括アップロード、編集する

概要

タイトルの通りです
結論的には正規表現を駆使しましょうということです

環境

  • macOS 10.14.3
  • gsutil 4.36

一括アップロード

  • gsutil cp ep*.m4a gs://test-bucket-12345/

すべてのファイルを一括公開する

  • gsutil acl ch -u AllUsers:R gs://test-bucket-12345/*

メタデータ (Content-Type) を一括編集する

  • gsutil setmeta -h 'Content-Type:audio/aac' gs://test-bucket-12345/*

参考サイト

2019年2月20日水曜日

Stackdriver で特定のページの ping チェックを行う

概要

Stackdriver は GCP と AWS で使える統合監視ツールです
エージェント方式でロギングやサーバのメトリックの収集を行います
本来は GCP や AWS のインスタンスの監視を行うためのサービスですが Stackdriver には「Uptime Checks」というサービスがあります
これを使えば外部のサイトの ping チェックのような死活監視を行うことができます
またアラートの通知も豊富などでいろいろなところに通知することができます
今回は特定のホストを Uptime Checks で監視する方法とそのアラートを Slack に通知する方法を紹介します

環境

  • Stackdriver (2019/02/18 時点)

Uptime Checks の作成

まずは Uptime Checks を作成します
左メニューから「Uptime Checks」を選択し Overview を表示しましょう
一覧に移動して「Add Uptime Checks」を選択します

「Uptime Checks」を作成するダイアログが表示されるので必要な情報を入力します
今回は特定の https ページのヘルスチェックを行います

  • Title・・・任意の名前を設定します
  • Check Type・・・HTTPS を選択します
  • Resource Type・・・URL を選択します
  • Hostname・・・ヘルスチェックを行いたい任意の URL を入力します
  • Path・・・必要であればパスを設定します

これで OK です
「Advanced Options」でヘッダの設定やレスポンスボディに含まれる文字列のチェック、認証情報などを設定可能です
これも必要があれば設定しましょう
ダイアログ下部に「Test」があるのでテストすると良いと思います
200 が返ってくればチェックできています

作成が完了したら各リージョンからのチェックが始まります
すべて緑ならページが健全だということになります

Alerting の作成

もしページがダウンした場合にアラートを送信します
今回は Slack に通知してみます

Slack チャネルを作成する

まずこのページから Slack チャネルを作成します

「Add Slack Channel」を選択します

「Authorize Stackdriver」を選択します
Slack アプリを登録するための OAuth のページが開くので認証します

もしチームが違う場合はログインし直しましょう

「Test Connection」を選択して Slack に通知が来るかテストしましょう
問題なければ「Save」でチャネルを保存します

Policy の作成

左メニューの「Alerting」から Overview を表示します
右上の「Add Policy」を選択します

まずは「Add Condition」でアラートの条件を作成します

「Target」と「Configuration」を設定します
「Target」は以下の通りです

  • Resource Type・・・Uptime Monitoring Url を選択します
  • Uptime check id・・・Test page を選択します、これは先程作成した Uptime Checks になります

「Configuration」は以下の通りです
基本はそのままで大丈夫だと思います

  • Condition triggers if・・・Any time series violates
  • Condition・・・is above
  • Threshold・・・1
  • For・・・1 minute

一番上に Condition 名がすでに設定されています
問題なければ「Save」で Condition を作成します

次に Notifications を設定します
「Add Notification Channel」で先程作成した Slack チャネルを設定しましょう

あとは Policy 名を設定すれば OK です
問題なければ「Save」で Policy を作成しましょう

これで死活監視とアラートの設定は完了です
もしテストできるならアプリやファイアウォールでアクセスできないようにしてみましょう
Slack に通知が来れば OK です

最後に

Stackdriver を使って Uptime Checks を行ってみました

参考サイト

2019年2月19日火曜日

ブラウザ版の Gmail で通知を受け取る方法

概要

Chrome or Firefox ならブラウザの通知機能が使えます
新着メールが来た際に通知する方法を紹介します

環境

  • Chrome 71.0.3578.98

1. 通知を ON にする

Gmail -> 設定 -> デスクトップ通知

2. chrome の settings の確認

chrome://settings/content/notifications

3. タブを開きっぱなしにする

これが重要で Gmail をタブで開きっぱなしにしておかないと通知が来ません
おそらく Service Worker は使っていないのか (不明)
とりあえず自分の場合は Gmail を開いた状態でないと来ませんでした

2019年2月18日月曜日

Linked Clone を Ruby で試してみた

概要

Linked Clone は差分クローンでフルクローンに比べて高速に VM をクローンすることができます
API 的に CloneVM_Task を使いますがある条件下で CloneVM_Task をコールすることで Linked Clone になります
今回は rbvmomi を使って Linked Clone API をコールしてみました

環境

  • CentOS 7.5.1804
  • Ruby 2.5.0p0
  • rbvmomi 2.0.1

Linked Clone with rbvmomi

とりあえず素の状態の VM から Linked Clone な VM を作成するスクリプトです

  • vim clone_vm.rb
require 'rbvmomi'

src = 'src'
dest = 'dest'
dc = 'dc'

vim = RbVmomi::VIM.connect(
  host: '192.168.100.10',
  user: 'vcenter-user',
  password: 'vcenter-pass',
  insecure: 'true'
)
dc = vim.serviceInstance.find_datacenter(dc)
vm = dc.find_vm(src)
disks = vm.config.hardware.device.grep(RbVmomi::VIM::VirtualDisk)
disks.select { |x| x.backing.parent == nil }.each do |disk|
  spec = {
    :deviceChange => [
      {
        :operation => :remove,
        :device => disk
      },
      {
        :operation => :add,
        :fileOperation => :create,
        :device => disk.dup.tap { |x|
          x.backing = x.backing.dup
          x.backing.fileName = "[#{disk.backing.datastore.name}]"
          x.backing.parent = disk.backing
        },
      }
    ]
  }
  vm.ReconfigVM_Task(:spec => spec).wait_for_completion
end
relocateSpec = RbVmomi::VIM.VirtualMachineRelocateSpec(:diskMoveType => :moveChildMostDiskBacking)
spec = RbVmomi::VIM.VirtualMachineCloneSpec(:location => relocateSpec,
                                   :powerOn => false,
                                   :template => false)
vm.CloneVM_Task(:folder => vm.parent, :name => dest, :spec => spec).wait_for_completion

ポイントは一度、素の VM に対して ReconfigVM_Task を実行している点です
「素」の VM というのは差分ディスクを持たない VM のことをいいます
この素の VM に対して CloneVM_Task を実行してもフルクローンになってしまいます
VirtualDisk のデバイスを新規に追加し追加したディスクの親ディスクを指定することで差分ディスクを作成することができます

実行結果

下の図は素の VM に対して ReconfigVM_Task を実行して差分ディスクを追加した状態のデータストアの状況です


ReconfigVM_Task 実行後

下は src_1.vmdk という差分ディスクが増えていることがわかります
また mob で「src」VM の VirtualDisk の backing の情報を見ると parent がリンク状態になり存在していることがわかります

「dest」VM のデータストアを見てみると極小の vmdk ファイルのみが作成されています

vCenter Web クライアントから見えるタスク的には再設定後に通常のクローンのタスクが実行されているように見えます
なのでタスクだけ見ても Linked Clone かどうかはわかりません

スナップショットでもいいのか

差分ディスクと言えばスナップショットです
先程のサンプルでは差分ディスクをコード上で追加しました
スナップショットを事前に作成しておくことで同じことができないかも試してみました
まず再度「素」の VM を用意しましょう
そして API でも Web クライアントでも何でもいいのでスナップショットを作成します

差分ディスクは 000001 という名前が自動で振られます

この状態で Linked Clone を実行してみましょう
今度は新規でディスクを追加するロジックをごっそり削除しています

  • vim clone_vm2.rb
require 'rbvmomi'

src = 'src'
dest = 'dest'
dc = 'dc'

vim = RbVmomi::VIM.connect(
  host: '192.168.100.10',
  user: 'vcenter-user',
  password: 'vcenter-pass',
  insecure: 'true'
)
dc = vim.serviceInstance.find_datacenter(dc)
vm = dc.find_vm(src)
relocateSpec = RbVmomi::VIM.VirtualMachineRelocateSpec(:diskMoveType => :moveChildMostDiskBacking)
spec = RbVmomi::VIM.VirtualMachineCloneSpec(:location => relocateSpec,
                                   :powerOn => false,
                                   :template => false)
vm.CloneVM_Task(:folder => vm.parent, :name => dest, :spec => spec).wait_for_completion

これで実行しても Linked Clone になりました
ということでスナップショットを事前に作成しておくだけでも Linked Clone にすることができます
要するに元の VirtualDisk を親ディスクとして差分ディスクを追加すれば何でも良いようです

その他

bVmomi::VIM.VirtualMachineRelocateSpec:diskMoveType => :moveChildMostDiskBacking は地味に必須です
これがないと Linked Clone になりません
どういうパラメータかというとクローンする際に使用するディスクを親ディスクから共有するというパラメータになります
つまり、子ディスク (差分ディスク) だけ移動して VM をクローンすることを意味するのでこれで Linked Clone な VM を作成することができます
これを指定しないとフルクローンになるので注意が必要です

また Linked Clone VM がある状態で差分ディスクを削除することはできませんでした
ただ Web Client から試しにスナップショットを削除してみたところスナップショット自体は削除できたのですがデータストアにはまだ src-000001.vmdk が残っていました
要するに親子関係が残った状態の差分ディスクだけは削除されないということになります
スナップショットは削除できるので差分ディスクもないかなと思いきや差分ディスクはあったので気付きとしてメモしておきます

最後に

Linked Clone を rbvmomi で試してみました
最大のポイントを親ディスクを参照する差分ディスクを事前に作成しておく点かなと思います
Linked Clone は差分ディスクだけを使って VM をクローンする手法なので差分ディスクの作成自体はスナップショットなどの方法でも OK です

2019年2月17日日曜日

コサイン類似度を学ぶ

概要

前回、自分のツイートの TFIDF を算出してツイート内から特徴語を算出してみました
今回はさらに算出した TFIDF を使って各ツイートの類似度をコサイン類似度をクラスタリングしてみたいと思います

環境

  • macOS 10.14.3
  • Ruby 2.5.1p57

コサイン類似度を算出するスクリプト

前回、算出した各ツイートの単語ごとの TFIDF の結果を読み込んで算出します

  • bundle init
  • vim Gemfile
gem "natto"
  • bundle install --path vendor
  • vim cosine_similarity.rb
require 'json'

class CosSim
  attr_accessor :all_tweet_count

  def initialize
    file = 'results.json'
    @data = JSON.parse(File.read(file))
    @all_tweet_count = @data.size
    @results = []
  end

  def calc(index: 0)
    @source = @data['results'][index]
    @data['results'].each_with_index { |target, i|
      r = Result.new(target['tweet'], cos(target))
      @results.push(r)
    }
  end

  def clear
    @results.clear
  end

  def show_result(top = 10)
    ret = {}
    ret.store(:source, @source['tweet'])
    rr = []
    sorted = @results.sort { |a, b| b.cos <=> a.cos }.first(top)[1..-1]
    sorted.each { |result|
      r = {}
      r.store(:target, result.tweet)
      r.store(:cos, result.cos)
      rr.push(r)
    }
    ret.store(:result, rr)
    puts ret.to_json
  end

  def cos(target)
    term_dimensions = []
    @source['terms'].each { |t|
      term_dimensions.push(t['term']) unless term_dimensions.include?(t['term'])
    }
    target['terms'].each { |t|
      term_dimensions.push(t['term']) unless term_dimensions.include?(t['term'])
    }
    source_tfidf_map = []
    target_tfidf_map = []
    term_dimensions.each { |dim|
      sterm = @source['terms'].select { |t| t['term'] == dim }.first
      tterm = target['terms'].select { |t| t['term'] == dim }.first
      if sterm.nil?
        source_tfidf_map.push(0.0)
      else
        source_tfidf_map.push(sterm['tfidf'].to_f)
      end
      if tterm.nil?
        target_tfidf_map.push(0.0)
      else
        target_tfidf_map.push(tterm['tfidf'].to_f)
      end
    }
    a = Math.sqrt(source_tfidf_map.inject(0){ |sum, tfidf| sum + tfidf * tfidf })
    b = Math.sqrt(target_tfidf_map.inject(0){ |sum, tfidf| sum + tfidf * tfidf })
    ab = (target_tfidf_map.zip(source_tfidf_map).map { |t, s| t * s }).sum
    cos = ab / (a * b)
  end

  class Result
    attr_accessor :tweet, :cos

    def initialize(tweet, cos)
      @tweet = tweet
      @cos = cos
    end
  end
end

cs = CosSim.new
10.times do
  i = Random.rand(0..10015)
  cs.calc(index: i)
  cs.show_result(4)
  cs.clear
end

少し解説

そもそもコサイン類似度は「ある文書 (ここではツイート) とある文書が似ている文書なのか」を算出することができる計算方法です
ツイートは全部で 10,000 ほどあるので単純にすべてのツイートに対してコサイン類似度を出そうとすると 10,000 * 10,000 = 1億回 の処理が必要になります
時間があるのであれば放置しておけばいいですが、そうもいきません
上記のスクリプトは cs.calc(index: 100) で対象のツイートのインデックスを指定することできるのであるツイートに対して類似するツイートを 10,000 件の中から探すようになっています
もし全件やりたい場合は all_tweet_count 分メイン処理でループさせれば OK です

処理の流れとしてまず類似度を比較する文書 2 つの中に出現する単語のベクトルを作成します
ベクトルの大きさは TFIDF を採用します
イメージ的には以下の通りです

文書 ギガ 使用
ツイート1 0.38 0 0.18 0.13
ツイート2 0.22 0 0.01 0

そのためにまず文書間で出現する単語のユーニク (term_dimensions) を算出します
そしてその単語ごとに前回の結果から TFIDF を算出し source_tfidf_maptarget_tfidf_map に追加します
単語が出現しない場合は 0.0 を追加します

あとは作成したそれぞれの map からコサイン類似度の計算に必要な値を算出します
Math.sqrt(source_tfidf_map.inject(0){ |sum, tfidf| sum + tfidf * tfidf }) は各単語の tfidf を二乗して、それらの sum を計算しています
更に計算された合計に対して平方根をとります
それを同様に target_tfidf_map でも行います

(target_tfidf_map.zip(source_tfidf_map).map { |t, s| t * s }).sum はそれぞれのマップの各要素の tfidf の積を計算しそれを sum しています

あとはこれらの値からコサイン類似度を求めます (cos = ab / (a * b))
ここで算出された値は -1 から 1 の間を取ります
1 に近ければ近いほど類似度が高くなります

結果を少し見てみる

先程も述べたようにすべてのツイートの類似度を見るのは時間がかかるので適当にサンプリングして見てみました

例えば以下の結果は「元気」というワードがどのツイートにも出てきており「似ていると言えば似ているな、、、」という感じなのがわかります
cos の数値を見ても 0.36 くらいなので 1 には遠いと考えるとニュアンス的にはそんな感じなのかなと思います

{
  "source": "大学生、、、元気だなw",
  "result": [
    {
      "target": "arduino ってまだまだ元気なのか、、なつかしい、、",
      "cos": 0.36860955705104625
    },
    {
      "target": "フィッツすっかり元気になっているじゃないか",
      "cos": 0.32779605613840673
    },
    {
      "target": "エシャロットって体に良いとかあるのかな。なんか食べた翌日は元気な気がする。たまたまかなw",
      "cos": 0.2422238044523076
    }
  ]
}

cos が 0.5 を超える類似度だと以下の通りです
確かに似ているような気がします、、、

{
  "source": "storyboard 落ちるわー、、、",
  "result": [
    {
      "target": "気持ち落ちるでぇ、、、",
      "cos": 0.5008608639512765
    },
    {
      "target": "なんか、storyboard もめっちゃ変わってるな",
      "cos": 0.40529191248135504
    },
    {
      "target": "立ち上がりはいつも落ちるなー。",
      "cos": 0.39170065104194474
    }
  ]
}

今回は 10 個しかサンプルリングしなかったので何とも言えませんが cos が 0.75 以上くらいで絞り込めばかなり似ているツイートを探せる印象です
ただ感じ的にはほとんどが 0.1 から 0.2 くらいだったので高スコアなペアを探せるのは結構レアなケースかなと思います

最後に

各ツイートごとに TFIDF を算出したのでコサイン類似度を使ってツイートのクラスタリングも行ってみました

この方法でも確かに似ているツイートは探せると思います
ただ今回の手法ではあくまでも「似ている」ツイートを探せているだけであり意味やコンテキストが似ているかどうかまでは判断できていません

例えばサンプリングの結果が見せた「storyboard 落ちるわー、、、」と「気持ち落ちるでぇ、、、」は確かにツイート的には似ていますがコンテキストとしては前者が Xcode に関するツイートで後者は体調や気持ちに関するツイートです
なので似てはいますが内容は全く違います

更に類似しているツイートを探したいのであれば今回クラスタリングした結果から更に TFIDF -> コサイン類似度を算出すれば良いと思います
TFIDF を算出した元データ (文書集合) がツイート全体だったので、文書集合をクラスタリング単位にすれば結果も変わってくると思います

そもそも Twitter 自体全く同じツイートはできないのはなかなか難しいですが、おもしろいアプローチかなと思います (定石かもしれませんが)

参考サイト