2020年10月30日金曜日

flask のロギング機構について調べてみた

概要

flask のロギングをいろいろと試してみました
基本は python の標準の logging を使っているようです

環境

  • macOS 10.15.7
  • Python 3.8.5
    • flask 1.1.2

準備

  • pipenv install flask

とりあえず標準のログを確認してみる

  • vim app.py
from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello, World!'
  • FLASK_APP=app.py pipenv run flask run
  • curl localhost:5000

これで確認すると以下のようなログが表示されるのが確認できると思います

127.0.0.1 - - [30/Oct/2020 09:17:47] "GET / HTTP/1.1" 200 -

app.logger を使う

特にフォーマットをしてせずにログ出力してみます
flask では logging モジュールを使っているのでそれに対してログレベルの調整などをします
デフォルトだと warn になっている info にしてから出力してみます

  • vim app.py
import logging
from flask import Flask

app = Flask(__name__)
logging.basicConfig(level=logging.INFO)

@app.route('/')
def hello_world():
    app.logger.info('called hello world')
    return 'Hello, World!'
  • FLASK_APP=app.py pipenv run flask run
  • curl localhost:5000
INFO:app:called hello world INFO:werkzeug:127.0.0.1 - - [30/Oct/2020 09:27:37] "GET / HTTP/1.1" 200 -

こんな感じで表示されます
前半にログレベルとパッケージ名が付与されているのが確認できます

フォーマットを変更する

次にフォーマットを変更してみます
logging.configdictConfig というメソッドがあるのでこれにログの設定を与えることで変更することができます

  • vim app.py
from logging.config import dictConfig
from flask import Flask

dictConfig({
    'version': 1,
    'formatters': {'default': {
        'format': '[%(asctime)s] %(levelname)s in %(module)s: %(message)s',
    }},
    'handlers': {'wsgi': {
        'class': 'logging.StreamHandler',
        'stream': 'ext://flask.logging.wsgi_errors_stream',
        'formatter': 'default'
    }},
    'root': {
        'level': 'INFO',
        'handlers': ['wsgi']
    }
})

app = Flask(__name__)

@app.route('/')
def hello_world():
    app.logger.info('called hello world')
    return 'Hello, World!'

formatters, handlers を使って定義します
Python の logging モジュールで使用できるハンドラの一覧はこちらが参考になります
今回使用している StreamHandler は標準出力と標準エラーに出力するためのハンドラになります
ファイルに出力したい場合は FileHandler を使います
handler 内でログのフォーマットを指定します
またログレベルも dictConfig で指定できます

これで実行すると以下のように出力されます

  • FLASK_APP=app.py pipenv run flask run
  • curl localhost:5000
[2020-10-30 09:34:08,250] INFO in app: called hello world [2020-10-30 09:34:08,251] INFO in _internal: 127.0.0.1 - - [30/Oct/2020 09:34:08] "GET / HTTP/1.1" 200 -

ちなみに flask のデフォルトハンドラはすべて標準エラーに出力しています

メールの送信

エラーロギングなどをメールで飛ばすこともできます
その場合は SMTPHandler を使います
先程は dictConfig を使って dictionary 形式でロガーを設定しましたがハンドラを生成してアプリに設定することもできます
ちなみに Gmail を使う場合は以下のように設定します

  • vim app.py
import logging
from logging.handlers import SMTPHandler
from flask import Flask

mail_handler = SMTPHandler(
    mailhost=('smtp.gmail.com', 587),
    credentials=('your-google-account@gmail.com', 'app-password'),
    fromaddr='your-google-account@gmail.com',
    toaddrs=['your-google-account@gmail.com'],
    subject='Application Error',
    secure=()
)
mail_handler.setLevel(logging.ERROR)
mail_handler.setFormatter(logging.Formatter(
    '[%(asctime)s] %(levelname)s in %(module)s: %(message)s'
))

app = Flask(__name__)
app.logger.addHandler(mail_handler)

@app.route('/')
def hello_world():
    app.logger.error('An error has occurred')
    return 'Hello, World!'

これでアクセスすると標準エラーに出力されるのと同時にメールが届くのも確認できると思います
メール送信時に若干ネットワークのラグが発生するのでレスポンスが遅くなります

  • FLASK_APP=app.py pipenv run flask run
  • curl localhost:5000
[2020-10-30 10:03:51,142] ERROR in app: An error has occurred

request の情報をログに入れる

自作でフォーマッタを用意すれば request の内容を常にログに入れることもできます
default_hander に対して設定していますが独自のハンドラに設定することもできます
また default_handler の出力を表示するために development モードで起動しましょう

  • vim app.py
import logging
from flask import has_request_context, request
from flask.logging import default_handler
from flask import Flask

app = Flask(__name__)

class RequestFormatter(logging.Formatter):
    def format(self, record):
        if has_request_context():
            record.url = request.url
            record.remote_addr = request.remote_addr
        else:
            record.url = None
            record.remote_addr = None

        return super().format(record)

formatter = RequestFormatter(
    '[%(asctime)s] %(remote_addr)s requested %(url)s '
    '%(levelname)s in %(module)s: %(message)s'
)

default_handler.setFormatter(formatter)
default_handler.setLevel(logging.INFO)

@app.route('/')
def hello_world():
    app.logger.info('called hello world')
    return 'Hello, World!'

確認すると以下のようなログが追加で表示されるのが確認できると思います

  • FLASK_ENV=development FLASK_APP=app.py pipenv run flask run
  • curl localhost:5000
[2020-10-30 11:09:58,170] 127.0.0.1 requested http://localhost:5000/ INFO in app: called hello world

デフォルトの werkzeug ロガーのフォーマットを変更する

デフォルトで表示されているリクエストログは werkzeug というモジュールのロガーになります
これにハンドラを追加すればフォーマットを変更できます
例えば先程の RequestFormatter にする場合は以下のようにします

import logging
from flask import has_request_context, request
from flask.logging import default_handler
from flask import Flask

app = Flask(__name__)

class RequestFormatter(logging.Formatter):
    def format(self, record):
        if has_request_context():
            record.url = request.url
            record.remote_addr = request.remote_addr
        else:
            record.url = None
            record.remote_addr = None

        return super().format(record)

formatter = RequestFormatter(
    '[%(asctime)s] %(remote_addr)s requested %(url)s '
    '%(levelname)s in %(module)s: %(message)s'
)

default_handler.setFormatter(formatter)
default_handler.setLevel(logging.INFO)

werkzeug_logger = logging.getLogger('werkzeug')
werkzeug_logger.addHandler(default_handler)

@app.route('/')
def hello_world():
    app.logger.info('called hello world')
    return 'Hello, World!'
  • FLASK_ENV=development FLASK_APP=app.py pipenv run flask run
  • curl localhost:5000
[2020-10-30 11:16:14,753] 127.0.0.1 requested http://localhost:5000/ INFO in app: called hello world [2020-10-30 11:16:14,754] None requested None INFO in _internal: 127.0.0.1 - - [30/Oct/2020 11:16:14] "GET / HTTP/1.1" 200 -

最後に

flask のロギング機構について調べてみました
基本はハンドラを作成してそのハンドラに対してログレベルやフォーマットを設定し作成したハンドラをアプリのロガーに設定するという流れになります
ハンドラは自分で作成もできますし Python にいくつか標準で準備されているハンドラがあるので自分のやりたいことにあったハンドラを使えば OK です

参考サイト

2020年10月29日木曜日

Celery の concurrency について試してみた

概要

Celery のワーカの並列数を指定するにはワーカを起動する際に --concurrency オプションを指定します
今回は --concurrenty の使い方とポイントを紹介します

環境

  • macOS 10.15.7
  • Python 3.8.5
    • celery 4.4.7

とりあえず concurrency を使ってみる

とりあえずワーカ 1 つで concurrency を使ってみます
ワーカは簡単なワーカを使います
効果を確認するために sleep を入れています

  • vim sub_tasks.py
import time
from celery import Celery

app = Celery('sub_tasks', backend='redis://localhost', broker='redis://localhost')

@app.task
def add(x, y):
    time.sleep(10)
    return x + y

メインは以下の通りです

  • vim main.py
from sub_tasks import add

for i in range(10):
    ret = add.delay(100, i)
    print(ret)

ワーカを起動します
--concurrency=10 で起動してみます

  • pipenv run celery -A sub_tasks worker --loglevel=info --concurrency=10

これでメインを起動してみます

  • pipenv run python main.py

main.py は非同期で実行されたジョブの ID が表示されてすぐに終了します
ワーカ側の処理を見ると 10 個のジョブがすべて同時に実行されているのが確認できると思います

タスクごとに concurrency を変更したい場合には

ワーカごとに concurrency を変更したい場合は単純に起動するワーカごとに concurrency 引数の値を変更すれば OK です
Celery でタスクごとに concurrency を変更できるのかというと結論としてはバージョン 4 系ではできません (参考)
なのでタスクごとに concurrency を変更したい場合はワーカに分割しかつキューも専用のキューを指定する必要があります

sub_tasks2.py を作成して動作確認してみます
task_routes でワーカが処理対象とするキューを指定できます

  • vim sub_tasks2.py
import time
from celery import Celery

app = Celery('sub_tasks', backend='redis://localhost', broker='redis://localhost')
app.conf.task_routes = {'sub_tasks2.multi': {'queue': 'multi'}}

@app.task
def multi(x, y):
    time.sleep(10)
    return x * y

そして既存の sub_tasks.py も修正します
こちらも処理対象のキューを指定しましょう

  • vim sub_tasks.py
import time
from celery import Celery

app = Celery('sub_tasks', backend='redis://localhost', broker='redis://localhost')
app.conf.task_routes = {'sub_tasks.add': {'queue': 'add'}}

@app.task
def add(x, y):
    time.sleep(10)
    return x + y

メインのスクリプトは上記 2 つのワーカを呼び出すように変更します

  • vim main.py
from sub_tasks import add
from sub_tasks2 import multi

for i in range(10):
    ret = add.delay(100, i)
    print(ret)
    ret = multi.delay(100, i)
    print(ret)

あとはワーカをそれぞれ起動するだけですがこのときも、どのキューに対して処理するのかを指定します
また今回は動作確認として sub_tasks2 側のワーカを --concurrency=1 で起動して並列数が 1 で実行されるか確認します

  • pipenv run celery -A sub_tasks worker --loglevel=info --concurrency=10 -Q add
  • pipenv run celery -A sub_tasks2 worker --loglevel=info --concurrency=1 -Q multi

これで準備 OK です
あとはメインを実行します

  • pipenv run python main.py

メインはこれまで通りすぐに結果が返ってくるのが確認できると思います
そしてワーカは sub_tasks 側は 10 個のジョブがすべて並列で終了するのに対して sub_tasks2 側はジョブは 1 つずつ終了するのが確認できると思います

気になったこと

sub_taksk2 側のワーカがジョブを受け取る際に一気に 5 個のジョブを受け取ってそれぞれ 1 つずつ処理し 1 つ処理が終わるとまた 1 つジョブを受け取る感じでした
sub_tasks 側はジョブの受け取りも実行も同時に 10 個ずつ行っていました

もしかするとワーカのジョブの受け取り方も concurrency が影響しているのかもしれません

最後に

Celery のワーカの並列数について挙動を確認してみました
タスクごとに並列数を変更したい場合はワーカにしてかつキューも指定する必要があることがわかりました

もしかするとバージョンが上がればタスクごとに並列数を指定することができるオプションが追加されるかもしれません

参考サイト

2020年10月28日水曜日

envoy 超入門

概要

モダンプロキシサーバの envoy を試してみました
イメージとしては nginx や HAProxy などの代替になるツールという印象です
とりあえずデモにあるプロキシを経由するとすべてのアクセスが特定のサイトのコンテンツを表示するサンプルを試してみました

環境

  • macOS 10.15.7
  • envoy 1.16.0

インストール

Mac の場合は Homebrew でインストールすることが可能です

  • brew install envoy

--version オプションでバージョンが確認できます

  • envoy --version

Getting Started

とりあえず試してみましょう
まずは起動します

  • wget https://www.envoyproxy.io/docs/envoy/latest/_downloads/f6e613dcd48bb592b753313e7cfb1b28/envoy-demo.yaml
  • envoy -c envoy-demo.yaml

localhost:10000 で envoy が起動しています

  • curl localhost:10000

アクセスすると www.envoyproxy.io の画面が表示されると思います
これは envoy-demo.yaml を見るとわかりますがすべてのアクセスを www.envoyproxy.io に流すように定義されているためです

filter_chains:
- filters:
  - name: envoy.filters.network.http_connection_manager
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
      stat_prefix: ingress_http
      http_filters:
      - name: envoy.filters.http.router
      route_config:
        name: local_route
        virtual_hosts:
        - name: local_service
          domains: ["*"]
          routes:
          - match:
              prefix: "/"
            route:
              host_rewrite_literal: www.envoyproxy.io
              cluster: service_envoyproxy_io

例えば localhost:10000/try にアクセスすると www.envoyproxy.io/try のページが表示されます

管理画面にアクセスする

また localhost:9901 で管理画面が起動しているのでブラウザでアクセスしてみましょう

こんな感じで envoy の各種設定や動作しているホストの情報を確認することができます

管理画面にアクセスできる定義も envoy-demo.yaml 内で定義されています

admin:
  access_log_path: /dev/null
  address:
    socket_address:
      address: 0.0.0.0
      port_value: 9901

最後に

macOS 上で envoy をとりあえず動かしてみました
k8s 上でサービスメッシュを実現するというコンテキストで使われることが多いようですが単体でも動作することが確認できました

設定ファイルを YAML で定義してプロキシへのアクセスをすべて特定のサイトに移動するようなデモを試してみました
nginx でも同じようなことができるのでイメージしやすいデモだったかなと思います

今回はルーティングルールを静的に記載しましたが envoy はこれを動的に行うこともできるようです (参考: Examples Dynamic)

参考サイト

2020年10月27日火曜日

Ubuntu18.04 で squid をインストールして使ってみる

概要

サクッと Web Proxy がほしい場合には squid が簡単です
今回は Ubuntu で試してみました
squid のバージョンは若干古いです (執筆時の最新版は 4.13)

環境

  • Ubuntu 18.04 (on Vagrant)
  • squid 3.5.12

インストール

  • sudo apt -y update
  • sudo apt -y install squid

すべての http/https サイトにアクセスできるようにする

  • vim /etc/squid/squid.conf
http_access allow all
  • sudo systemctl daemon-reload

起動

  • sudo systemctl restart squid

動作確認

とりあえず curl で確認します

  • http_proxy=192.168.100.10:3128 https_proxy=192.168.100.10:3128 curl https://kaka-request-dumper.herokuapp.com/

Ruby でも確認してみます

  • vim test.rb
require 'net/http'

proxy_addr = '192.168.100.10'
proxy_port = 3128

res = Net::HTTP.new('kaka-request-dumper.herokuapp.com', nil, proxy_addr, proxy_port).start { |http|
  http.get('/')
}

puts res.body
  • ruby test.rb

ちゃんとプロキシを経由してもアクセスできることが確認できると思います

最後に

とりあえず Web プロキシがほしい場合は squid が簡単そうです
バージョンが古かったり詳細なセキュリティ設定はしていないので限られた環境であればこれくらいの設定でも十分機能すると思います

2020年10月26日月曜日

Ruby で i18n をやってみる (with Sinatra)

概要

Ruby の i18n ジェムを使ってみました
単体で使用する方法と Sinatra で使用する方法を紹介します

環境

  • macOS 10.15.7
  • Ruby 2.7.1p83
    • i18n 1.8.5

インストール

  • bundle init
  • vim Gemfile
gem "sinatra"
gem "i18n"
  • bundle install

単体で使用する

まずは単体で使用する方法を紹介します
各言語ごとにプロパティファイルを作成します

  • mkdir config/locales
  • vim config/locales/jp.yml
jp:
  name: "ほーくすのうろぐ"
  age: 20
  • vim config/locales/en.yml
en:
  name: "hawksnowlog"
  age: 10

あとは i18n を使って YAML ファイルの値を参照するだけです
default_locale で言語の出し分けをします

  • vim app.rb
require 'i18n'

I18n.load_path << Dir[File.expand_path("config/locales") + "/*.yml"]
I18n.default_locale = :jp
# I18n.default_locale = :en

puts I18n.t(:name)
puts I18n.t(:age)
  • bundle exec ruby app.rb

単体ではこんな感じで動作させます

Sinatra で使ってみる

configure や before を使っています
helpers を使ってもいいかなと思います
ブラウザは Accept-Language ヘッダを見てロケールを設定するのでそのようにしています
ヘッダは String なので Symbol に変換してから使いましょう

  • vim app.rb
require 'i18n'
require 'sinatra/base'

class MyApp < Sinatra::Base
  configure do
    I18n.load_path << Dir[File.expand_path("config/locales") + "/*.yml"]
    I18n.config.available_locales = [:en, :jp]
  end

  before do
    unless request.env["HTTP_ACCEPT_LANGUAGE"].nil?
      lang = request.env["HTTP_ACCEPT_LANGUAGE"].to_sym
      I18n.locale = I18n.config.available_locales.include?(lang) ? lang : :en
    end
  end

  get '/' do
    "name => #{I18n.t(:name)}, age => #{I18n.t(:age)}"
  end
end
  • vim config.ru
require './app'
run MyApp
  • bundle exec rackup config.ru

動作確認してみます
Accept-Language ヘッダをいろいろと変更して確認してみると良いと思います

  • curl localhost:9292

=> name => hawksnowlog, age => 10

  • curl -H "Accept-Language: jp" localhost:9292

=> name => ほーくすのうろぐ, age => 20

  • curl -H "Accept-Language: fr" localhost:9292

=> name => hawksnowlog, age => 10

最後に

Ruby の i18n ジェムを使って国際化する簡単なサンプルを紹介してみました
Rails と組み合わせて使う例はたくさんあるのですが Sinatra は少なかったので Sinatra とも組み合わせてみました

YAML ファイルで簡単に切り分けできるのと他の言語に対応する場合は YAML ファイルを新規作成するだけなので便利です

参考サイト

2020年10月24日土曜日

(Ruby) クラス内で定義したインスタンスメソッドやクラスメソッドを CLI からコールする方法

概要

わざわざ実行スクリプトを作らなくても CLI から実行できます

環境

  • macOS 10.15.7
  • Ruby 2.7.1p83

サンプルコード

class MyClass
  def initialize(value)
    @value = value
  end

  def method
    puts @value
  end

  def self.class_method
    puts "class_method"
  end
end

インスタンスメソッドの呼び出し

  • ruby -r './app.rb' -e 'MyClass.new("hello").method'

クラスメソッドの呼び出し

  • ruby -r './app.rb' -e 'MyClass.class_method'

複数行になる場合

素直に irb を使います

irb(main):001:0> require './app.rb' => true irb(main):002:0> mc = MyClass.new('HELLO') irb(main):003:0> mc.method HELLO => nil irb(main):004:0> MyClass.class_method class_method => nil

最後に

-r で require し -e でスクリプトを実行できます
複数行になる場合は irb を使いましょう

参考サイト

2020年10月23日金曜日

shell2http でファイルをアップロードする方法

概要

shell2http でファイルを受け取る方法を紹介します
クライアント側は curl, ruby, python で試してみました

環境

  • macOS 10.15.7
  • shell2http 1.13

準備

過去の記事を参考にインストールしてください

form を使ってファイルを受け取る

--form オプションを使います
受け取ったファイルの情報はリダイレクトすることでファイルとして保存します

  • shell2http --form /upload 'cat $filepath_uploadfile > uploaded_file.dat'

curl で試す

test.dat というファイルを送ってみます

  • curl -X POST -F uploadfile=@./test.dat localhost:8080/upload

サーバ側では uploaded_file.dat という名前で保存されているのが確認できると思います
また uploadfile= の部分がフォームとして送られるファイルの名前になるのでここが uploadfile2 などになるとリクエストは送信できますがサーバ側で指定のファイルがないため uploaded_file.dat が空で生成されます
エラーにならないので注意してください

Ruby で試す

net/http の post_form を使っています

require 'net/http'
require 'uri'

url = URI.parse('http://localhost:8080/upload')
http = Net::HTTP.new(url.host, url.port)

req = Net::HTTP::Post.new(url.path)
filename = 'test.dat'
f = open("./#{filename}")
query = [
  [ 'uploadfile', f, { uploadfile: filename } ]
]
req.set_form(query, "multipart/form-data")

ret = http.request(req)
ret.to_hash

Python で試す

標準ライブラリの urllib を使いたかったのですが情報が少なく requests を使いました

  • pipenv install requests
  • vim upload.py
import requests

url = 'http://localhost:8080/upload'
file = {'uploadfile': open('test.dat', 'rb')}
res = requests.post(url, files=file)
print(res)
  • pipenv run python upload.py

最後に

shell2http で form を使ってファイルを受け取る方法を紹介しました
クライアント側は POST で multipart/form-data でファイルを送信すれば良さそうです

2020年10月21日水曜日

Grafana でメールチャネルを使ってアラートを送信する方法

概要

前回 Grafana で Slack にアラートを通知してみました
今回は Email チャネルを設定してメール送信してみたいと思います

admin ログインとアラートの有効化

前回の記事を参考に設定してください

SMTP サーバの設定

これはなぜか設定ファイルを直接編集する必要があります
UI からでは設定できません

  • vim /var/opt/gitlab/grafana/grafana.ini
[smtp]
enabled = true
host = smtp.gmail.com:587
user = your-google-account-name@gmail.com
password = your-app-password
skip_verify = false
from_address = your-google-account-name@gmail.com
from_name = your-google-account-name

そしたら grafana だけ再起動します
reconfigure を掛けてしまうと grafana.ini も上書きされてしまうので注意してください

  • gitlab-ctl restart grafana

Grafana でメールチャネルを作成

Grafana のダッシュボードからメールチャネルを作成しましょう

画面の下にテストメールを送信できるボタンがあるのでそれを押してテスト送信してみましょう
こんな感じでメールが届けばちゃんと SMTP の設定ができています

あとはアラートを待つだけ

あとはアラートを設定して来るのを待ちます
アラートの設定方法は過去の記事を参考にしてください

最後に

Gitlab に付属の Grafana を使ってメールでアラート通知をしてみました
STMP の情報は UI からではなく直接設定ファイルに記載する必要があるようです
Gitlab の場合は reconfigure を掛けると設定が上書きされてしまうのでそこだけ注意しましょう

2020年10月20日火曜日

nginx の server_names_hash_bucket_size の挙動を確認してみる

概要

server_names_hash_bucket_size ディレクティブは nginx で VirtualHost を使う場合に影響するディレクティブになります
VirtualHost として指定する server_name が長い場合などにエラーが発生することがあります
今回は server_names_hash_bucket_size の適切なサイズを見極めるために挙動を確認してみました

環境

  • macOS 10.15.7
  • nginx 1.19.3

まずは確認用のアプリを作成する

何でも OK です
今回は nginx 単体で動作確認します

nginx.conf

2 つの server ディレクティブを定義します
下が動作確認用で上は Host ヘッダが設定されていない場合のデフォルトで表示する server になります
server_names_hash_bucket_size はデフォルトの 32 で設定しています

  • vim /usr/local/etc/nginx/nginx.conf
worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile      on;
    keepalive_timeout  65;
    server_names_hash_bucket_size  32;

    server {
        listen       80;
        server_name  localhost;

        location / {
            root   html;
            index  index.html;
        }
    }

    server {
        listen       80;
        server_name  test-server.dev;

        location / {
            root   html;
            index  index2.html;
        }
    }
}

確認用 html

どちらが表示されているか確認できるないようであれば何でも OK です

  • vim /usr/local/var/www/index.html
<html>
<head>
</head>
<body>
index
</body>
</html>
  • vim /usr/local/var/www/index2.html
<html>
<head>
</head>
<body>
index2
</body>
</html>

テストしてみる

  • brew services start nginx

で起動し curl で Host ヘッダを設定し異なるコンテンツが返ってくるか確認しましょう

  • curl localhost

=> index

  • curl -H 'Host: test-server.dev' localhost

=> index2

が返ってくることを確認します

server_name を長くしてエラーが発生するか確認する

まずは could not build server_names_hash, you should increase server_names_hash_bucket_size: 64 というエラーが発生するか確認します
想定では server_name を長くすれば発生するはずです
例えば以下のように長くしてみましょう

  • vim /usr/local/etc/nginx/nginx.conf
server_name  test-server.aaaaaaaaaabbbbbbbbbbccccccccccaaaaaaaaaabbbbbbbbbbcccccccccc.dev;

76 文字あります
これで nginx を再起動してみるとうまく起動していないことが確認できます

  • brew services restart nginx

またログを見ると該当のログが表示されているのも確認できると思います

  • less /usr/local/var/log/nginx/error.log
2020/10/20 14:37:05 [emerg] 22865#0: could not build server_names_hash, you should increase server_names_hash_bucket_size: 64

ちなみにこの指示通りに server_names_hash_bucket_size: 64 に変更してもなぜかエラーは止まりませんでした
更に上の 128 を指定したところ上記の設定でも動作するを確認しています
なぜ 64 ではダメで 128 で動作するのかは謎です

ギリギリのラインを確認する

先程は 76 文字の適当な server_name を設定しました
今度はエラーになるギリギリの文字数を見つけてみます

  • vim /usr/local/etc/nginx/nginx.conf
server_name  test-server.aaaaaaaaaabbbbbbbbbbcccccccccc.dev;

上記のように 46 文字を指定したところでエラーが止まりました
これならば server_names_hash_bucket_size: 32 でも動作します
しかし一文字増やすだけで 64 でなく 128 が必要になります

別の server ディレクティブの影響を受けるのか確認する

server_name の情報をマッピングするハッシュを生成している可能性があるので別の server ディレクティブを追加してみます
まずは以下のようにしましょう

  • vim /usr/local/etc/nginx/nginx.conf
worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile      on;
    keepalive_timeout  65;
    server_names_hash_bucket_size 32;

    server {
        listen       80;
        server_name  localhost;

        location / {
            root   html;
            index  index.html;
        }
    }

    server {
        listen       80;
        server_name  test-server.aaaaaaaaaabbbbbbbbbbcccccccccc.dev;

        location / {
            root   html;
            index  index2.html;
        }
    }

    server {
        listen       80;
        server_name  new.test-server.dev;

        location / {
            root   html;
            index  index3.html;
        }
    }
}

これは server_names_hash_bucket_size: 32 でも動作しました
これだけ見ると server ディレクティブの数には影響しないように見えます
一応更に動作確認として new2, new3, new4 まで作りましたが特に問題なく動作しました

ただドキュメントを見ると server_name を大量に定義した場合は server_names_hash_max_size ディレクティブのチューニングが必要とあったのでもしかすると 50 とか 100 ほど server ディレクティブを定義すればエラーが発生するかもしれません

結論

結論としては数字と server_name の相関関係ははっきりとわかりませんでした
nginx のコードを読めばハッシュの生成方法がわかると思いますがそこまでは追っていません
なので現状は自分もとりあえずエラーが発生したら値を増やせという雑な対応しかできないところです
使用している CPU のラインキャッシュが影響するようなのでマシンによっては大きな値は設定できないという可能性もでてきそうです

参考サイト

2020年10月19日月曜日

Python から Prometheus のメトリックを取得してみる

概要

前回 Ruby から Prometheus のメトリックを取得してみました
今回は Python から取得してみたいと思います

環境

  • macOS 10.15.7
  • Python 3.8.5
    • prometheus-api-client

インストール

  • pipenv install prometheus-api-client

すべてのメトリックのクエリを取得

取得可能なメトリックの一覧を取得してみます
配列でメトリック名が取得できます
このメトリック名と時間を使って特定の期間のメトリックを取得できます

  • vim app.py
from prometheus_api_client import PrometheusConnect

prom = PrometheusConnect(url ="http://192.168.100.10:9090", disable_ssl=True)
print(prom.all_metrics())
  • pipenv run python app.py

特定のメトリック情報を取得する

例えば redis_memory_used_bytes を取得してみます
最新の 10 分間だけ取得する場合には get_metric_range_data を使います
label_config には特定のメトリックを取得ために設定されているキーバリューのラベルを設定します
あとは metric_name にメトリック名を指定すれば OK です

import datetime
from prometheus_api_client import PrometheusConnect

prom = PrometheusConnect(url ="http://192.168.100.10:9090", disable_ssl=True)
label_config = {'instance': 'localhost:9121', 'job': 'redis'}
metric_data = prom.get_metric_range_data(metric_name='redis_memory_used_bytes', label_config=label_config)
print(metric_data)

t = metric_data[0].get('values')[-1][0]
v = metric_data[0].get('values')[-1][1]
print(datetime.datetime.fromtimestamp(t))
print(int(v) / (1000 * 1000))

values という配列にメトリックデータが入っています
-1 で参照しているのは配列の最後に最新のデータが入っているためです
時刻は unix time なので変換し値はバイト単位なのでメガバイトに変換しています

時間を指定して取得する

10 分間ではないレンジを指定したい場合は get_metric_range_data を使います
時間の指定は prometheus_api_client が用意してくれている parse_datetime が使えます
chunk_size は取得できるメトリック数をいくつかのチャンクに分けて取得してくれます
例えば以下のサンプルのように 30 分間のメトリックを取得する際に chunk_size=6mins などにすると 5 分間隔のメトリックデータに分割して取得してくれます

import datetime
from prometheus_api_client import PrometheusConnect
from prometheus_api_client.utils import parse_datetime

prom = PrometheusConnect(url ="http://192.168.100.10:9090", disable_ssl=True)
label_config = {'instance': 'localhost:9121', 'job': 'redis'}
start_time = parse_datetime("30min")
end_time = parse_datetime("now")
chunk_size = datetime.timedelta(minutes=30)

metric_data = prom.get_metric_range_data(
    'redis_memory_used_bytes{instance="localhost:9121",job="redis"}',
    start_time=start_time,
    end_time=end_time,
    chunk_size=chunk_size,
)
print(metric_data)
print(len(metric_data[0].get('values')))

t = metric_data[0].get('values')[-1][0]
v = metric_data[0].get('values')[-1][1]
print(datetime.datetime.fromtimestamp(t))
print(int(v) / (1000 * 1000))

最後に

Python から Prometheus のメトリックを取得してみました
基本は Ruby と同じでメトリック名+ラベル+時間のレンジを指定することでメトリック情報を取得することができます
時間や値の変換方法が言語で異なる他、データ取得のためのヘルパーメソッドが用意されているかいないかが違いかなと思います

参考サイト

2020年10月16日金曜日

Ruby から Prometheus のメトリックを取得してみる

概要

過去に自分のアプリにエクスポータを追加する方法を紹介しました
今回はエクスポータから取得して Prometheus に溜まったメトリックデータを Ruby から取得してみたいと思います
使用するライブラリは prometheus/prometheus_api_client_ruby になります

環境

  • macOS 10.15.7
  • Ruby 2.7.1p83
    • prometheus-api-client 0.6.2

インストール

  • bundle init
  • vim Gemfile
gem "prometheus-api-client"
  • bundle install

サンプルコード: メトリックの取得とデータ変換

例えば redis-exporter から取得して Prometheus に溜まっているデータを取得するには以下のようなコードになります

  • vim app.rb
require 'prometheus/api_client'

prometheus = Prometheus::ApiClient.client(url: 'http://192.168.100.10:9090')

now = Time.now
a_hour_ago = now - 60 * 60

response = prometheus.get(
  'query_range',
  query: 'redis_memory_used_bytes{instance="localhost:9121",job="redis"}',
  start: a_hour_ago.strftime("%FT%T%:z"),
  end:   now.strftime("%FT%T%:z"),
  step:  '120s',
)

puts response.status
puts response.body

result = JSON.parse(response.body)
t = result["data"]["result"].first["values"].first[0]
v = result["data"]["result"].first["values"].first[1]

puts Time.at(t)
puts v.to_i / (1024.0 * 1024.0)

クエリに関しては Prometheus の UI で確認できるクエリをそのまま入力すれば OK です

データは基本的に時間のレンジで指定するのがいいので start と end を指定しましょう
時刻はフォーマットが決まっているので指定のフォーマットに併せて変換しています (例: 2020-10-16 09:58:44 +0900)

取得できるデータは JSON になっています
時刻データと実際の値が配列になっているのでそれぞれ取得しています
あとは必要なデータ形式に変換すれば OK です
サンプルでは見やすいように時刻を変換しているのとバイトデータとメガバイト単位に変換しています

最後に

prometheus/prometheus_api_client_ruby を使って Prometheus に溜まったデータを取得してみました
これを元に独自のアラートルールや UI が作成できるようになります

参考サイト

2020年10月15日木曜日

Sinatra の time_for ヘルパーメソッドを使ってみる

概要

Sinatra の time_for は Time オブジェクトを生成するためのヘルパーメソッドです
今回はどんな感じで使えるのか試してみました

環境

  • macOS 10.15.7
  • Ruby 2.7.1p83
    • sinatra 2.0.7

とりあえず使ってみる

引数には Time オブジェクトを生成する文字列を指定することができます
Time.parse で指定可能な形式ならなんでも OK です

  • vim app.rb
require 'sinatra/base'

class MyApp < Sinatra::Base
  get '/' do
    t = time_for('Oct, 15 2020')
    # t = time_for('2020/10/15 10:09:55')
    # puts t.class # -> Time
    t.to_s
  end
end

last_modified や expires ヘルパーメソッドに影響する

last_modifiedexpires はヘッダを自動的に付与するヘルパーメソッドです
これらのヘッダは基本的に時刻を返却するようになっています
Sinatra では last_modifiedexpires は内部的に time_for を呼び出しているので time_for をオーバライドすることでそれらを使いやすくすることができます

require 'sinatra/base'

class MyApp < Sinatra::Base
  helpers do
    def time_for(value)
      case value
      when :yesterday then Time.now - 24*60*60
      else super
      end
    end
  end

  get '/' do
    last_modified :yesterday
    'ok'
  end
end

デフォルトの time_for に関してはこのあたりのソースを読むとわかりやすいと思います

最後に

Sinatra の time_for ヘルパーメソッドを試してみました
他のヘルパーメソッドメソッドからも参照されているのでデフォルトの time_for をオーバライドすることでいろいろと使えそうなヘルパーメソッドにすることができそうです

2020年10月14日水曜日

Ruby の connection_pool の挙動を確認してみる

概要

Ruby の connection_pool を redis で使って挙動を確認してみました
connection_pool を使うことで意図しない大量の接続が来たときにもデータベース負荷をかけずにアプリ側でコントロールできるようになります
今回はプールのサイズを超えて接続した場合の挙動などを確認します

環境

  • macOS 10.15.7
  • Ruby 2.7.1p83
    • connection_pool 2.2.3

プールサイズ確認用のスクリプト

特に redis に対してコマンドは発行していませんが state_check のブロック内で cp.available で残りのプール数を表示しています
(1..3) の Range の部分でプールのサイズを調整できるのここを変更することで挙動を確認します

require 'connection_pool'
require 'redis'

def state_check(cp)
  cp.with do |redis_cli|
    puts cp.available
    sleep 5
  end
end

cp = ConnectionPool.new(size: 5, timeout: 3) {
  Redis.new
}
threads = []

(1..3).each do
  threads << Thread.new do
    state_check(cp)
  end
end

threads.each { |t| t.join }

ちなみに上記スクリプトを実行するとプール数が 4, 3, 2 と減って表示されるのが確認できます
なおコマンドが実行されていないので redis-cli の CLIENT LIST で見ても表示はされません

プールサイズを超えてみる

では実際にプールサイズを超えて実行してみます
先程の Range の (1..3)(1..6) にするだけです

すると Waited 3 sec (ConnectionPool::TimeoutError) のエラーが発生するのが確認できると思います
これはプールサイズが 5 に対して同時に 6 接続した場合に 1 つは接続待ち状態になります
接続待ち状態になったプールは ConnectionPool.new(size: 5, timeout: 3) で指定した timeout 秒待ってプールに空きが出なければ上記のエラーを raise しているのです

なので正常に終わらせたいのであれば timeout を 10 などに変更してみましょう
すると今度は raise せずに 4, 3, 2, 1, 0, 4 と表示されて正常終了するはずです

ここまで確認すればわかるのですがプールが開放されるのは with ブロック内で実行しているコマンドなどが終了してブロックが終了した段階でプールが開放されるようです

with 内で raise された場合はどうなるのか

with 内で意図的に raise してブロックが最後まで行かなかった場合にどうなるのかも確認してみました
確認スクリプトを少し修正しています

require 'connection_pool'
require 'redis'

def state_check(cp, i)
  cp.with do |redis_cli|
    begin
      puts cp.available
      sleep 5
      raise Exception
    rescue Exception => e
      puts "error on #{i}"
    end
    redis_cli.set("result#{i}", 0)
  end
end

Thread.report_on_exception = false
cp = ConnectionPool.new(size: 5, timeout: 10) {
  Redis.new
}
threads = []

(1..6).each do |i|
  threads << Thread.new do
    state_check(cp, i)
  end
end

threads.each { |t| t.join }
puts "end"

スクリプトが悪い可能性もありますが一応これで 6 つのプールの処理がすべて正常に終了することが確認できました
with のブロックが何かしらの処理で止まったりしてもブロックを抜けさえすればプールは開放されるようです

最後に

connection_pool を使って Redis に接続できるクライアント数の制御をしてみました
プールサイズを超えた場合はタイムアウト秒待ちそれでもプールに空きがない場合はエラーになることが確認できました

2020年10月13日火曜日

Ruby で redis にあるデータをオブジェクトにバインドする方法

概要

Ruby で Redis を扱う場合にデータ構造が決まっている場合はオブジェクトにしたい場合があります
そんな場合は redis-objects というライブラリを使うと簡単にオブジェクト操作できます

環境

  • macOS 10.15.7
  • Ruby 2.7.1p83
    • redis-objects 1.5.0

インストール

  • bundle init
  • vim Gemfile
gem "redis-objects"
  • bundle install

接続

内部的には redis ライブラリを使っているので初期化方法などは redis ライブラリと同じように行えます

  • vim app.rb
require 'redis'

Redis.current = Redis.new(
  :host => '127.0.0.1',
  :port => 6379
)

データを保存する

データを保存する場合も一度 redis-objects のオブジェクトを作成してから保存します
redis のオブジェクトとして操作するクラスは必ず Redis::Objects を include する必要があります

今回は単純な文字列を保存してみます
Redis::Objects には様々なタイプが用意されており文字列を保存したい場合には value を使って宣言します
そしてキーを一意に特定するために必ず id というメソッドを実装する必要があります

  • vim app.rb
require 'redis'
require 'redis/objects'

Redis.current = Redis.new(
  :host => '127.0.0.1',
  :port => 6379
)

class User
  include Redis::Objects
  value :name
  def id
    1
  end
end

user = User.new
user.name = "hawk"

これで実行すると redis 側には以下のようなデータが保存されているのが確認できると思います

127.0.0.1:6379> keys *
1) "user:1:name"
127.0.0.1:6379> get user:1:name
"hawk"

データを取得する

次に保存したデータを取得してみましょう
とは言ってもやることはオブジェクトを作成するだけです
データを参照する場合は .value を使います

  • vim app.rb
require 'redis'
require 'redis/objects'

Redis.current = Redis.new(
  :host => '127.0.0.1',
  :port => 6379
)

class User
  include Redis::Objects
  value :name
  def id
    1
  end
end

user = User.new
puts user.name.value # => hawk

カウンタを使う

先程は value というタイプを使いました
次は counter というタイプを使ってみます
これは数字の情報を redis で管理する他にカウントアップするための専用のメソッドが用意されています
increment を呼び出すと値を 1 つプラスしてくれます

  • vim app.rb
require 'redis'
require 'redis/objects'

Redis.current = Redis.new(
  :host => '127.0.0.1',
  :port => 6379
)

class User
  include Redis::Objects
  counter :my_posts
  def id
    1
  end
end

user = User.new
user.my_posts.increment
user.my_posts.increment
user.my_posts.increment
puts user.my_posts.value # 3
user.my_posts.reset
puts user.my_posts.value # 0
user.my_posts.reset 5
puts user.my_posts.value # 5
127.0.0.1:6379> keys *
1) "user:1:name"
2) "user:1:my_posts"
127.0.0.1:6379> type user:1:my_posts
string
127.0.0.1:6379> get user:1:my_posts
"5"

配列を使う

配列も扱えます
list を使ってキーを宣言します

require 'redis'
require 'redis/objects'

Redis.current = Redis.new(
  :host => '127.0.0.1',
  :port => 6379
)

class User
  include Redis::Objects
  list :favorites
  def id
    1
  end
end

user = User.new
['ruby', 'swift', 'python'].each do |lang|
  user.favorites << lang
end
127.0.0.1:6379> LLEN user:1:favorites
(integer) 3
127.0.0.1:6379> LRANGE user:1:favorites 0 -1
1) "ruby"
2) "swift"
3) "python"

ハッシュを使う

ハッシュも扱えます
hask_key を使ってキーを宣言します

  • vim app.rb
require 'redis'
require 'redis/objects'

Redis.current = Redis.new(
  :host => '127.0.0.1',
  :port => 6379
)

class User
  include Redis::Objects
  hash_key :score
  def id
    1
  end
end

user = User.new
user.score['japanese'] = 10
user.score['arithmetic'] = 20
user.score['science'] = 30
127.0.0.1:6379> hgetall user:1:score
1) "japanese"
2) "10"
3) "arithmetic"
4) "20"
5) "science"
6) "30"

直接 redis オブジェクトを使う

get や set といった redis のコマンドを直接実行することもできます
ただその場合はキーの指定も直接行う必要があります
クラス名、id メソッド、キー名を使って自動生成される redis のキーを指定します

  • vim app.rb
require 'redis'
require 'redis/objects'

Redis.current = Redis.new(
  :host => '127.0.0.1',
  :port => 6379
)

class User
  include Redis::Objects
  hash_key :score
  def id
    1
  end
end

score = User.redis.hgetall('user:1:score')
puts score

=> {"japanese"=>"10", "arithmetic"=>"20", "science"=>"30"}

最後に

redis-objects を使って redis にあるデータをオブジェクトにシリアライズしてから使用する方法を紹介しました
コード上でどういったデータが redis に入っているのか一目で確認することができるのも嬉しい点かなと思います

こちらのほうがオブジェクト指向っぽく書けますがデータ構造が変わった場合はクラス側も修正必要があるので手間であります
今回は基本的なシリアライズ/デシリアライズの方法しか紹介しませんでしたがカスタムシリアライザも作成できるので複雑なデータのオブジェクト化も自作すれば可能になります

参考サイト

2020年10月12日月曜日

Ruby の hashie を使ってハッシュを使いやすくする

概要

hashie/hashie は Ruby のハッシュを拡張するためのライブラリです
ハッシュのフィールドにアクセスするときにスクエアブラケットではなくドットにしたりハッシュに対して型成約を付けたりすることができます
今回はいろいろな使い方のサンプルを紹介します

環境

  • macOS 10.15.7
  • Ruby 2.7.1p83

Getting Started

とりあえず使ってみましょう
既存のハッシュから Hashie::Mash オブジェクトを作成すればキーにドットでアクセスできるようになります
ネストしている Array は Hashie::Array、Hash は Hashie::Mash として変換されています

require 'hashie'

profile = Hashie::Mash.new({
  name: 'hawk',
  age: 10,
  langs: [
    'ruby',
    'swift',
    'python'
  ],
  score: {
    'japanese': 10,
    'arithmetic': 20,
    'science': 30,
  }
})
puts profile.name
puts profile.age
puts profile.langs.first
puts profile.score.science

型を強制する

ハッシュには型がありませんが Coercion という機能を使うと入力するハッシュの型成約ができます
Hashie::Extensions::CoercionHashie::Extensions::MergeInitializer を include しましょう
そして coerce_key を使って型成約するフィールドを指定します

class Profile < Hash
  include Hashie::Extensions::Coercion
  include Hashie::Extensions::MergeInitializer

  coerce_key :name, String
  coerce_key :age, Integer
end

profile = Profile.new({
  name: 'hawk',
  age: 10
})
puts profile[:name]
puts profile[:age]

強制した上でドットでフィールドにアクセスする

先程は普通にハッシュに対して型成約しただけなのでフィールドにアクセスする場合はスクウェアブラケットを使います
それだと Hashie っぽくないのでドットでアクセスできるようにする場合は Hashie::Mash を継承しましょう

class Profile < Hashie::Mash
  include Hashie::Extensions::Coercion
  include Hashie::Extensions::MergeInitializer

  coerce_key :name, String
  coerce_key :age, Integer
end

profile = Profile.new({
  name: 'hawk',
  age: 10
})
puts profile.name
puts profile.age

強制なので強制できない場合は変換メソッドのデフォルトの値になる

例えば coerce_key :age, Integer した場合は age フィールドには数値が入ることが想定されます
しかし以下のように数値以外が来た場合には to_i メソッドの返り値がそのまま入ります

class Profile < Hashie::Mash
  include Hashie::Extensions::Coercion
  include Hashie::Extensions::MergeInitializer

  coerce_key :name, String
  coerce_key :age, Integer
end

profile = Profile.new({
  name: 'hawk',
  age: 'snowlog'
})
puts profile.name
puts profile.age # -> 0

この場合 age は 0 で初期化されます
なぜなから 'snowlog'.to_i の結果が 0 になるからです

クラスの入れ子にすると自動で初期化してくれる

例えば強制するハッシュクラス内に別のクラスがある場合にはそのクラスの initialize を自動で読んでセットしてくれます

class Score < Hashie::Mash
  include Hashie::Extensions::Coercion
  include Hashie::Extensions::MergeInitializer

  coerce_key :japanese, Integer
  coerce_key :arithmetic, Integer
  coerce_key :science, Integer
end

class Profile < Hashie::Mash
  include Hashie::Extensions::Coercion
  include Hashie::Extensions::MergeInitializer

  coerce_key :name, String
  coerce_key :age, Integer
  coerce_key :score, Score
end

profile = Profile.new({
  name: 'hawk',
  age: 10,
  score: {
    japanese: 20,
    arithmetic: 30,
    science: 40
  }
})
puts profile.name
puts profile.age
puts profile.score.japanese

入れ子になるクラスは Hashie::Mash を継承していなくてもいい

入れ子にするクラスは既存のクラスを使いたい場合もあると思います
そんな場合はハッシュを受取る initialize を定義すればそちらを自動で読んでくれます

class Score
  attr_accessor :japanese, :arithmetic, :science

  def initialize(score)
    @japanese = score[:japanese]
    @arithmetic = score[:arithmetic]
    @science = score[:science]
  end
end

class Profile < Hashie::Mash
  include Hashie::Extensions::Coercion
  include Hashie::Extensions::MergeInitializer

  coerce_key :name, String
  coerce_key :age, Integer
  coerce_key :score, Score
end

profile = Profile.new({
  name: 'hawk',
  age: 10,
  score: {
    japanese: 20,
    arithmetic: 30,
    science: 40
  }
})
puts profile.name
puts profile.age
puts profile.score.japanese

coerce_key は lambda を受け取ることもできる

型が 1 つじゃない可能性がある場合は coerce_key を lambda で定義することもできます

class Profile < Hashie::Mash
  include Hashie::Extensions::Coercion
  include Hashie::Extensions::MergeInitializer

  coerce_key :name, String
  coerce_key :age, Integer
  coerce_key :point, lambda { |v|
    if v > 0
      v
    else
      'error'
    end
  }
end

profile = Profile.new({
  name: 'hawk',
  age: 'snowlog',
  point: -1
})
puts profile.name
puts profile.age
puts profile.point

アロー演算子を使った場合は以下のように定義できます

class Profile < Hashie::Mash
  include Hashie::Extensions::Coercion
  include Hashie::Extensions::MergeInitializer

  coerce_key :name, String
  coerce_key :age, Integer
  coerce_key :point, ->(v) do
    if v > 0
      v
    else
      'error'
    end
  end
end

最後に

Ruby の Hashie を使ってハッシュの拡張をしてみました
とりあえずハッシュのフィールドにドットでアクセスするだけでも便利かなと思います
他にもキーが存在しない場合にはエラーにしたり定義していないキーを無視したりといろいろな機能があります

ハッシュの自由度を少し制限してしまう感じもあるので型強制については使いすぎると Ruby の良さも失ってしまうかもしれません
また型強制することで本来入らないであろうデータが入ってきてエラーを握りつぶす可能性もあるのでその辺りの考慮も必要になるかなと思います
リレーショナルなデータベースなどで型が厳密に決まっている場合には使えそうな気がします

参考サイト

2020年10月9日金曜日

Gitlab の Grafana でアラート機能を試してみた

概要

過去 に Gitlab の Grarana にアクセスする方法を紹介しました
どうやらアラート機能も使えるようなので試してみました

環境

  • GitLab Enterprise Edition 13.3.5-ee
  • grafana 7.0.3

admin でログインできるようにする

アラート機能を使うには admin 権限が必要です
Gitlab の Grafana はデフォルトでは Gitlab の OAuth のみでログインするようになっており権限が readonly しかないので admin でログインできるようにする必要があります

admin ログイン用のフォームを表示させる

まずは admin がログインできるようにログインフォームを表示するようにします

  • vim /etc/gitlab/gitlab.rb
grafana['disable_login_form'] = false
  • gitlab-ctl reconfigure

これで reconfigure をかければ grafana のログイン画面に OAuth 以外のログインフォームが表示されるようになります

admin ユーザのパスワードを設定する

これは CLI で行います
grafana['admin_password'] という項目が gitlab.rb にありますがこれではなく CLI で先にパスワードを設定する必要があります

  • gitlab-ctl set-grafana-password

これで新規に admin ユーザのパスワードを設定することができます
設定できたら Grafana の UI から「admin/設定したパスワード」でログインできるか確認してみましょう

アラート機能を有効にする

次にアラート機能を有効にします
デフォルトではなぜかアラート機能が使えないので有効にする必要があります

  • vim /etc/gitlab/gitlab.rb
grafana['alerting_enabled'] = true
  • gitlab-ctl reconfigure

これでアラート機能が有効になります

Notification channel を設定する

まずは Notification channel を新規で追加する必要があります
左メニューから追加しましょう

Slack や LINE、メールなどのチャネルに通知することができます
今回は Slack に通知してみます
Type に Slack を選択しあとは Incomming Webhook URL を設定しましょう

それ以外にも細かい設定ができますが今回は Incomming Webhook URL のみ設定します
チャネルやアイコンなど必要であれば設定しましょう
以下のようにチャネルの一覧に表示されれば OK です

アラートを設定してみる

これでようやくアラート機能が使えます
admin ユーザでログインして既存のダッシュボードを適当に選択して編集画面に移動しましょう

ダッシュボードの一覧からアラートしたい項目のダッシュボードを選択します
今回は「Gitlab Omnibus - Redis」を選択します

そしてダッシュボードから「Memory Usage」パネルを選択し「Edit」を選択します

アラート用のクエリを作成する

まずはアラート用のクエリを作成します
いくつかクエリが設定されているのですがアラート用には作成されておらずアラートが作成できないので新規でクエリを作成します

Query タブから新規で追加します
今回は Metrics に「redis_memory_used_bytes」を指定します

クエリを新規で作成した「Alert」タブに移動します
そして「Create Alert」を選択しましょう

アラートを作成する

まずアラートの条件を設定します
今回は 5 分間の値がある一定以上であれば通知するようにします
条件は以下のようになります

単位が bytes なので注意しましょう
設定しているしきい値の値がリアルタイムにグラフに反映されるのでわかりやすいと思います

次に通知するチャネルを設定します
先程作成した Slack チャネルを使います
通知する際のメッセージを設定可能なので好きなメッセージを設定します
本来はここに変数を使って現在の値などを表示したいのですがやり方がわかりませんでした
調べると他の人も悩んでいるのでまだやり方がないのかもしれません (参考)

作成できたら右上の「Apply」を押してアラートを作成します

ダッシュボードにもアラートのしきい値が表示されるようになります

動作確認

あとはアラートが上がるまで待ちましょう
今回の条件であれば 5 分後に 100% アラートが上がるはずです

問題なく Grafana でアラートの監視が始まっていればダッシュボードで Pending の線が表示されるようになります
これが表示されない場合はダッシュボードにアラートのルールがうまく保存されていないので以下のトラブルシューティングなどを参考に解決してください

トラブルシューティング: Template variables are not supported in alert queries

ダッシュボードからアラート以外で使用するクエリを削除してみましょう
もしくはアラート専用のダッシュボードを作成して再度クエリとアラートの登録を行ってみてください

トラブルシューティング: Provisioning: Cannot save provisioned dashboard

Gitlab が用意している既存のダッシュボードに対して操作すると出る場合があるようです
どうやら Gitlab が用意している Grafana の監視ファイルは JSON で保存されています

  • ls -l /opt/gitlab/embedded/service/grafana-dashboards
total 176
-rw-r--r-- 1 root root 14858 Sep  4 18:25 gitaly.json
-rw-r--r-- 1 root root 12103 Sep  4 18:25 nginx.json
-rw-r--r-- 1 root root 22528 Sep  4 18:25 overview.json
-rw-r--r-- 1 root root 20974 Sep  4 18:25 postgresql.json
-rw-r--r-- 1 root root 17716 Sep  4 18:25 praefect.json
-rw-r--r-- 1 root root 12019 Sep  4 18:25 rails-app.json
-rw-r--r-- 1 root root   542 Sep  4 18:25 README.md
-rw-r--r-- 1 root root 24709 Sep  4 18:25 redis.json
-rw-r--r-- 1 root root 11682 Sep  4 18:25 registry.json
-rw-r--r-- 1 root root 22238 Sep  4 18:25 service_platform_metrics.json

これらのファイルに対して UI から更新を掛ける場合は allowUiUpdates = true なる属性の設定が必要なようで Gitlab ではそれが設定されていないためにエラーになるようです

なのでその場合は新規でダッシュボードを作成して同じクエリと同じアラートルールを追加すれば OK です

最後に

Gitlab に付属の Grafana でアラート機能を試してみました
有効にするのが少し面倒なのとアラートのルールを設定できるのは admin ユーザだけのようです

有効にできればあとは Grafana の世界の話なのでクエリの設定の仕方などは Grafana のドキュメントを参考にすると良いかなと思います

参考サイト