読者です 読者をやめる 読者になる 読者になる

Terraform 0.9がリリース。0.8.xから0.9.xのStateマイグレーション手順をまとめました。

HashiCorpからTerraform 0.9がリリースされました。「よし、最新バージョンにあげよう。」と作業をはじめましたがremoteコマンドが使えない。どうやら0.9からはremoteコマンドが廃止されたようです。このエントリではTerraform 0.9にバージョンアップをして0.8以前のterraform stateをマイグレーションする方法をまとめます。

remoteコマンドの廃止

remoteコマンドが廃止になりました。代わりにbackendsを利用してS3などのremoteにあるtfstateファイルの管理を行います。

remote stateがbackendsに置き換わる過程は次のPull Requestから確認できます。 github.com

0.8以前を利用している場合はbackendを有効にしたtfstateファイルを用意する必要があります。次からは0.8.xまでのリソース状態を保持したまま新機能のbackendを有効にしたtfstateファイルへのマイグレーション手順についてまとめていきます。

マイグレーション手順

次の環境のマイグレーション手順になります。

  • 0.8.8から0.9.1へのバージョンアップする
  • これまではremoteにS3をつかっていて、これからもS3を利用する
  • ロールバックできるように、これまでのtfstateファイルは保持して新しいtfstateファイルを用意する
  • 0.8.80.9.1のterraformを使うのでtfenvを使ってterraformを切り替えながらマイグレーションを行う

1:tfファイルにterraformセクションを追加してbackends を設定する

次のように設定しました。

terraform {
  backend "s3" {
    bucket = "tfstate-bucket" // 自身のbucket名を設定します
  }
}
  • AWSaccess_key, secret_key, regionはそれぞれ環境変数AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGIONを設定しているため省略しています。
  • S3のkeyは必須ですが省略しています。後述するinitコマンドの-backend-config オプションで開発環境や本番環境ごとにS3のkeyを分けているためterraformセクションでは省略します。

※ その他bucketなどのS3の変数はこちらにまとまっています。

2:0.8.8のterraformをつかいremote configをしてtfstateファイルをローカルに同期する

tfenvでインストールしたバージョンリスト

terraform/dev ➤ tfenv list
0.9.1
0.8.8

0.8.8を使いremote configする

terraform/dev ➤ tfenv use 0.8.8
Terraform v0.8.8

terraform/dev ➤ terraform remote config -backend=S3 -backend-config="bucket=tfstate-bucket" -backend-config="key=dev"
Initialized blank state with remote state enabled!
Remote state configured and pulled.
  • S3のkeyは開発環境のdevとしています

3:0.9.1のterraformをつかいinitをしてtfstateファイルをマイグレーションする

terraform/dev ➤ tfenv use 0.9.1
Terraform v0.9.1

terraform/dev ➤ terraform init -backend-config "key=dev"
Initializing the backend...
New backend configuration detected with legacy remote state!
・・・省略・・・
  • 最初のaskでremote stateから変更するか?と聞かれるので yesを入力します。これをすることでtfstateファイル内のremotebackendに置き換わります。
  • 次のaskでremoteのstateをローカルのstateにコピーする?と聞かれるのでローカルのstateを保持したければnoを入力、コピーするのであればyesを入力します。すでにローカルにstateがあるのでnoと入力。

4:マイグレーションしたtfstateファイルをS3にアップロードする

マイグレーション後に0.8.8にロールバックするかもしれないので、0.8.8で運用したtfstateファイルを残したいです。そのため新しいS3のkeyをdev0.9と決めマイグレーションしたtfstateファイルをS3にアップロードします。

terraform/dev ➤ aws s3 cp ./.terraform/terraform.tfstate s3://tfstate-bucket/dev0.9

こうすることで開発中のtfstateファイルに影響が及ぶことはなくマイグレーションロールバックができる状態にします。

4:最後にplanを実行して新しいtfstateファイルにリソースの差分がないか確認する

terraform/dev ➤ rm -rf ./.terraform

terraform/dev ➤ tfenv use 0.9.1
Terraform v0.9.1

terraform/dev ➤ terraform init -backend-config "key=dev0.9"
Initializing the backend...
・・・省略・・・

terraform/dev ➤ terraform plan --refresh=false
No changes. Infrastructure is up-to-date.

This means that Terraform did not detect any differences between your
configuration and real physical resources that exist. As a result, Terraform
doesn't need to do anything.
  • 0.9.1からはremote configを使わずinitを使いtfstateファイルをローカルに同期します

まとめ

  • 0.9.1へtfstateファイルのマイグレーション手順をまとめました。
  • 0.8.xまではremote configを利用していましたが、0.9.1からはinitを利用します。
  • backendではtfstateのリソース情報がメモリ上に管理されます。0.8.xまではリソース状態がtfstateファイルを開けば確認できましたがbackendでは確認できません。リソース状態の管理がセキュアになりました。
  • backendはSTATE LOCKINGを機能が有効になります。複数人でapplyを実行した場合にstateをロックし競合を防ぎます。CIなどでapplyが同時に稼働しても安心です。
  • もし0.7.xからのマイグレーションの場合はリソース状態に差分が生まれているのでリソース状態を0.8系に合わせる必要があります。

参考URL

gRPC streamingをつかうとマイクロサービスの責務が整理できるし省コネクションでメリットあるよね、という話

今回はgRPCをマイクロサービス間通信に導入することってメリットあるよね、というエントリです。 定期的に処理を実行してくれるバッチはよくあるものですがバッチの駆動をgRPCを使って次のような構成で動かしました。

f:id:n_soushi:20170317102154j:plain

  • Batch ControlBatch ServerBidirectional gRPC streamingでコネクションする。
  • Batch ControlはRedisのPub/Subで特定のチャンネルを監視する。
  • Batch Controlはチャンネルにキューが投げられたらBatch Serverバッチ処理スタートのリクエストを送る。
  • Batch Serviceはリクエストを受け取りバッチを動かし処理結果をBatch Controlに送る(レスポンスを送る)。
  • チャンネルにキューが投げられる度に上記の流れでバッチを稼働させる。

上記の構成を踏まえ次からはメリットをまとめます。

gRPCをマイクロサービスに導入するとメリットあるよね

キューのRead権限をバッチサーバから剥がせる

キュー駆動でバッチを動かしている場合、例えばAmazon SQSを導入しているとRead権限が必要です。上記の構成であればキューを監視するのはバッチサーバではなくコントロールサーバになります。そのためキューを監視する権限をコントロールサーバに集約できるメリットがあります。

ログ集約サーバへの送信責務もバッチサーバから剥がせる

図のとおりgRPCのBidirectional streamingを使えば複数のレスポンスを送信することができます。バッチ処理結果や各種ログはコントロールサーバへ送り、ログ集約サーバへの送信はコントロールサーバが行います。gRPCで各サービスをつないでおいてログを送り、受けとったクライアントにログの集約を任せる、といった構成は導入メリットの1つな気がします。(ログの送信漏れ考慮は必要ですが)

そもそものgRPCのメリット

そもそものgRPCのメリットがあります。異なる言語のマイクロサービス間の通信でもProtocol Buffersを定義することで容易に通信を確立できますし、streamingの方式を用途に合わせて選択することで省コネクションでマイクロサービス間のやり取りが行えます。

GoとJavaでBidirectional gRPC streamingをつかったデモ

上記の図の構成をもとにgRPCのクライアントをGoサーバをJavaで通信方式はBidirectional streamingを採用してデモを作ってみました。

どのようなバッチサービス?

Bidirectional streamingを採用しているので、リクエストが複数あってレスポンスも複数、または1つのようなサービスを考えました。

結果、数値を受け取り割り算をして商と余りを返すサービスを実装しました。

Redisからキューを送信してクライアントがリクエストとレスポンスを受け取ったイメージです。

# Redis
$ redis-cli
127.0.0.1:6379> PUBLISH my_queue '{"serviceName" : "division", "numbers" : [10, 3]}'
# Client
12:27:50.452 Request : {serviceName:'division', message:'10', time:'time string'}
12:27:50.452 Request : {serviceName:'division', message:'3', time:'time string'}
12:27:50.455 Response: {serviceName:'division', message:'quotient:3', time:'time string'}
12:27:50.456 Response: {serviceName:'division', message:'remainder:1', time:'time string'}

クライアントは103のリクエストを2つ送り、商が3と余りが1の結果を受け取ります。(余りが0であればレスポンスは1つになる)

protoファイル

protoファイルは次のようになりました。

syntax = "proto3";

option go_package = "protobuf";
package proto;

service MicroService {
  rpc MicroService (stream Request) returns (stream Response) {}
}

message Request {
  string name = 1;
  string message = 2;
  string time = 3;
}

message Response {
  string name = 1;
  string message = 2;
  string time = 3;
}

クライアントのコード(Go)

リクエストを送信してレスポンスを受け取っている通信周りのコードの抜粋です。

※コード全体はgithubにあります。

waitc := make(chan struct{})
go func() {
    for {
        in, err := stream.Recv()
        if err == io.EOF {
            close(waitc)
            return
        }
        if err != nil {
            log.Error("Failed to receive a message : %v", err)
            return
        }
        responseLog.Info("{serviceName:'%s', message:'%s', time:'%s'}", in.Name, in.Message, in.Time)
    }
}()

for {
    message, err := pubSub.ReceiveMessage()
    if err != nil {
        panic(err)
    }
    requests, err := getRequests(message)
    if err != nil {
        panic(err)
    }

    for _, request := range requests {

        requestLog.Info("{serviceName:'%s', message:'%s', time:'%s'}", request.Name, request.Message, request.Time)
        if err := stream.Send(&request); err != nil {
            log.Error("Failed to send a message: %v", err)
        }
    }
}

stream.CloseSend()
<-waitc

サーバのコード(Java

リクエストを受け取りレスポンスを送信している通信周りのコードの抜粋です。

割り算をする数値が分けられて送られてきます。1回目のリクエストでキーを生成してリクエストを保持しながら2回目のリクエストで割った結果を送信しています。

※コード全体はgithubにあります。

return new StreamObserver<Microservice.Request>() {
    public void onNext(Microservice.Request req) {
        Long key = getTime(req);
        Observable.just(req)
                .subscribe(new Observer<Microservice.Request>() {

                    @Override
                    public void onSubscribe(Disposable d) {
                        Log.i("Request", getRequestLog(req));
                    }

                    @Override
                    public void onNext(Microservice.Request request) {
                        if (!routeNumber.containsKey(key)) {
                            routeNumber.put(key, Arrays.asList(req));
                        } else if (routeNumber.get(key).size() == 1) {

                            Microservice.Request prevRequest = routeNumber.get(key).get(0);
                            Integer leftTerm = Integer.parseInt(prevRequest.getMessage());
                            Integer rightTerm = Integer.parseInt(req.getMessage());

                            Integer quotient = leftTerm / rightTerm;
                            Integer remainder = leftTerm % rightTerm;

                            if (remainder == 0) {
                                responses.putIfAbsent(key, Arrays.asList(
                                        getResponse(req.getName(), String.format("quotient:%d", quotient))));
                            } else {
                                responses.putIfAbsent(key, Arrays.asList(
                                        getResponse(req.getName(), String.format("quotient:%d", quotient)),
                                        getResponse(req.getName(), String.format("remainder:%d", remainder))));
                            }
                        } else {
                            Log.w(String.format("waring, unknown state. key:{%s}, value:{%s}", key, routeNumber.get(key)));
                        }
                    }

                    @Override
                    public void onError(Throwable e) {
                        Log.e(String.format("onError %s", e.getMessage()));
                    }

                    @Override
                    public void onComplete() {
                        if (responses.containsKey(key)) {
                            Observable.fromIterable(responses.get(key))
                                    .subscribe(res -> {
                                        responseObserver.onNext(res);
                                        Log.i("Response", getResponseLog(res));
                                    });
                            routeNumber.remove(key);
                            responses.remove(key);
                        }
                    }
                });
    }

    public void onError(Throwable t) {
        logger.log(Level.WARNING, "microService cancelled");
    }

    public void onCompleted() {
        responseObserver.onCompleted();
    }
};

デモ

f:id:n_soushi:20170317114216g:plain

まとめ

  • Bidirectional streamingは1回のコネクションでクライアントとサーバ間で複数回のリクエストとレスポンスを送ることができます。リクエスト/レスポンスの度にコネクションを確率しないので省コネクションのメリットがあります。
  • クライアントはgRPCのコネクションを確立してからチャンネルのsubscribeを継続して行っています。キューが送られる度にgRCPのコネクションを繋いでいません。1回のgRCPコネクションを確立するだけでバッチサーバのコントロールが行うことができました。
  • gRPCで考えてみましたがHTTP/APIJSONの通信形式であっても権限や責務を1つのサーバに集約させるメリットは受けられます。何よりProtocol Buffers定義による複数言語のサポートとstreaming方式の便利さが運用しているマイクロサービスに嵌まれば導入機会を検討するべきです。引き続きgRPCのメリットを受けられるようなユースケースを考えていきます。

コードを公開しています

コード全体はgitbubで確認できます。

github.com

CourseraのMachine Learningから線形回帰を学んだのでまとめてみた

CourseraのMachine Learningを受講しています。時間を見つけてはコツコツ進めて今のところWeek4に差し掛かったところです。Week4ではNeural Networksの話に入り一段とレベルが高くなった印象を受けています。Week1からWeek3までに学んだことを復習する必要があるなと焦りが生まれました。

講中は配布される資料や動画を見たり他の日本人の方のブログを拝見したりと課題に取り組んできました。このタイミングで復習して整理することでWeek4以降の学習が快適になるのではないかと淡い期待を込めてWeek1からまとめてみます。

Machine Learning - Stanford University | Coursera

こちらが受講しているMachine Learningのコースです。MOOCは好きな時間に進められるし前編英語(動画は日本語字幕あり)なので英語の学習になってオススメです。

www.coursera.org

どこまでの内容をまとめるか

このエントリではWeek1の内容に触れています。

線形回帰とは?

ある土地の家の価格とその家の部屋数の相関をグラフで表すと以下のようになったとします。

f:id:n_soushi:20170310101210p:plain

Xが部屋数(RM)でYが価格(MEDV)です。

(出典:Housing Data Set

※ このデータは部屋数以外に、その土地の犯罪率だったり児童と教師の比率など複数の要素から構成されています。

部屋数が4つの場合や部屋数が7つの場合はグラフから予想できそうです。 このとき頭の中ではグラフに右肩上がりな直線をイメージして予想できますが、この直線を数式から導き出すことを学びました。 この導き方は統計学では回帰分析の一種として線形回帰と呼ばれています。

仮定関数と目的関数

部屋数をX、部屋の価格をYとすると相関を表すグラフを引くための1次関数の式は次のようになります。(懐かしい数式)

 \displaystyle
Y = aX + b

Machine Learningのコースではθをつかって次のような式で定義しています。

 \displaystyle
h_\theta (X) = \theta_0 + \theta_1(X)

 h_\theta (X)仮定関数と呼ばれます。家の価格予想に最適な直線を引くために \theta_0 \theta_1の数値を変えながらグラフにフィットした仮定関数を導き出します。 つまり右肩上がりの直線のグラフを引くために最適な  aX + babを決めるということですね。

仮定関数がフィットしているか計算するための関数である目的関数があります。

{ \displaystyle
J(\theta_0, \theta_1) = \frac{1}{2m}\sum_{i=1}^{m} (h_\theta (X_i) - Y_i)^2
}

この目的関数をつかって J(\theta_0, \theta_1)を最小にする \theta_0 \theta_1を導き出します。

最急降下法

目的関数を最小にする方法に最急降下法があります。

{ \displaystyle
\theta_j = \theta_j - \alpha \sum_{i=1}^{m} (h_\theta (x^{(i)}) - y^{(i)}) x_j\ ^{(i)}
}

ここで微分が出てきます。微分は傾きを求めますがこのアルゴリズムを使い傾きが最小になるまで学習を繰り返していきます。傾きの値が最小になるほどグラフにフィットした \theta_jが求められます。   \alphaは学習率と呼ばれ数値が大きいほど傾きの変動幅が大きくなりフィットしたデータが得られず、小さいほど学習はゆっくりと進み確実にデータにフィットした値が求められます。

Octaveでプログラム化する

コースの課題ではOctaveを使いプログラミング課題を提出します。 最初に示した家の価格と部屋数のグラフのデータを使い、更にこれまでのアルゴリズムからデータにフィットした直線をグラフにプロットしてみます。

目的関数

function J = computeCost(X, y, theta)
    m = length(y);
    J = sum((X*theta -y).^2) / (2* m);
end

最急降下法

function [theta, J_history] = gradientDescent(X, y, theta, alpha, num_iters)
    m = length(y);
    J_history = zeros(num_iters, 1);

    for iter = 1:num_iters
        theta = theta - alpha / m * X' * (X * theta -y);
        J_history(iter) = computeCost(X, y, theta);
    end
end

目的関数を求めグラフにプロットする。そして部屋数が5つのときの価格を予想する。

%% Initialization
clear ; close all; clc
data = load("housing.txt");
x = data(:, 6);
y = data(:, 14);
m = length(y);

theta = zeros(2, 1);
X = [ones(m, 1), x]

%% Compute Cost
J = computeCost(X, y, theta)

%% Gradient Descent
iterations = 1500;
alpha = 0.01;
[theta J_history] = gradientDescent(X, y, theta, alpha, iterations);

%% Output
fprintf('Initial cost = %f\n', J);
fprintf('Final cost = %f\n', J_history(iterations));
fprintf('Theta found by gradient descent: ');
fprintf('%f %f \n', theta(1), theta(2));

%% Plot data
figure; hold on;
plot(x, y, 'r+', 'LineWidth', 2);
plot(X(:,2), X*theta, '-')
xlabel('RM');
ylabel('MEDV');

%% Predict
fprintf('for RM = 5, MEDV = %f\n', [1, 5] *theta);

出力したグラフ

f:id:n_soushi:20170310115327p:plain

データにフィットした直線がプロットできたようです。

出力した数値

J =  296.07
initial cost 296.073458
final cost 27.131052
Theta found by gradient descent: -5.254087 4.477681
for RM = 5, MEDV = 17.134317

部屋数が5つのときに 17.134317と予想できました。グラフにプロットされたデータとフィットされているようです。

まとめ

  • 最後に出したグラフですが、もう少し角度が急な直線のほうがグラフにフィットしているようです。プロットして上手く行かなければ調整して再度プロットして、の繰り返しが機械学習の大事な工程なのでしょう。
  • 線形回帰はこの先で学ぶ機械学習の知識のベースになっています。データを分類する場合などに流用できます。
  • 今回の学習データは部屋数の1つでしたが部屋数に加え犯罪率児童と教師の比率などの複数の要素からも線形回帰をベースに家の価格を予想できる。(重回帰分析)この内容はWeek2で学びました。
  • MOOCで学び、コースを修了した先輩たちのブログで学び、自分のブログでアウトプットする、など多角的に学ぶことが大事。(ここに書籍を読んで学ぶも入れたい:money_with_wings: )
  • 数式書くの大変。

Page Object PatternをベースにTestCafeでE2Eテストを作ってみた


以前のエントリでKotlinでSelenideを使ったE2Eテストを作ったときもPage Object Patternを利用して見通しの良いテストコードが書けました。TestCafeでも同様にPage Object Patternを利用することが推奨されています

今回はTestCafeでもPage Object Patternを利用してテストを書いてみました。

何をテストするか

次のサイトのログイン認証をテストします。

freshlive.tv

テスト内容としては次のとおりです。

  • 認証画面のURLを開く
  • IDとパスワードを入力する
  • ログインボタンをクリックする
  • ログインが完了するとHome画面にリダイレクトされるので正しくリダイレクトされているか
  • ログインが完了するとヘッダーモジュールにアカウント名が表示されてるので正しく表示されているか

テストコードのフォルダ構成

プロジェクトのフォルダ構成は次のように役割ごとに整理しました。

tests
├── features
├── operators
├── pages
└── support
  • featuresにはスペックコードをまとめます。
  • operatorsにはPageオブジェクトから画面要素を参照してログインボタンをクリックするなどのオペレーションをまとめます。
  • pagesには画面の要素を参照できるようなPageオブジェクトを画面ごとにまとめます。今回は認証画面とHome画面をPageクラスにしました。
  • supportにはスペックコードでテストを進めるときに画面キャプチャを撮るなどのユーティリティ機能をまとめます。

次からはTestCafeのAPIを利用して記述したコード例を紹介します。

Pageクラス

TestCafeのAPIを利用して認証画面のPageクラスを作ります。

import { Selector } from 'testcafe';

export default class AuthPage {
    constructor () {

        this.url = 'https://freshlive.tv/auth/fresh_id';

        this.idInput = Selector('#user_id');
        this.pwInput = Selector('#password');

        this.submitBtn = Selector('button[type=submit]');
    }
}
  • 認証画面のURLを変数に定義する。
  • テストに必要な画面要素はSelectorを利用して定義する。
  • idInputはログインID、pwInputはパスワードの入力フィールドです。
  • submitBtnはログインボタンです。

これで認証画面の要素をまとめたAuthPageクラスができあがりました。

Operatorクラス

TestCafeのAPIを利用してOperatorクラスを作ります。
今回は認証画面でログイン認証する必要があるのでログイン操作をまとめます。

import {t} from 'testcafe';
import AuthPage from '../../pages/auth/auth-page.js';

export default class AuthOperator {
    constructor () {
        this.page = new AuthPage();
    }

    async open() {
        await t
            .navigateTo(this.page.url)
    }

    async authorize(id, password) {
        await this.open();

        await t
            .typeText(this.page.idInput, id)
            .typeText(this.page.pwInput, password)
            .click(this.page.submitBtn)
    }
}
  • constructor ()でAuthPageクラスからPageオブジェクトを生成します。
  • authorize(id, password)では認証画面を開きIDの入力とパスワードの入力を行い最後にログインボタンをクリックしています。typeTextclickで入力とクリックの操作ができます。
  • tはTestCafeのTestConrollerオブジェクトです。async-awaitとimportをすることでテスト実行時のTestConrollerオブジェクトと同期できます。0.13.0のリリース(Using test controller outside of test code (#1166))でTestConrollerをテストコード外でも参照することができるようになりました。(いいね!)

Supportクラス

TestCafeのAPIを利用してのSupportクラスを作ります。
今回は画面キャプチャを撮るユーティリティクラスを作りました。

import {t} from 'testcafe';

export default class ScreenshotSupport {
    constructor () {
    }

    async take() {
        await t
            .takeScreenshot;
    }
}
  • TestControllerには画面キャプチャを撮るメソッドが用意されています。
  • 取った画像を配置するフォルダもテスト実行時のオプションで指定できます。
  • 保存される画像のサイズは表示したブラウザのサイズになり縦長の画面の場合には画面全体を撮ることはできないようです。

Specクラス

TestCafeのAPIを利用してのSpecクラスを作ります。
このクラスでテスト内容をまとめていきます。

import { expect } from 'chai';

import HomePage from '../../pages/home/home-page.js';
import AuthOperator from '../../operators/auth/auth-operator.js';
import ScreenshotSupport from '../../support/screenshot-support.js';

const homePage = new HomePage();
const authOperator = new AuthOperator();
const screenshot = new ScreenshotSupport();

const userId = ' xxxxxxxx';
const password = 'xxxxxxxx';
const accountId = 'My Account';

fixture `auth fixtures`;

test('authorized at page then is appeared your account name on element of header.', async t => {

    // ログインする
    await authOperator.authorize(userId, password);

    // スクリーンショット撮る
    await screenshot.take();

    // ログイン後にHomeにリダイレクトをするのでURLが正しく切り替わっているか
    const docURI = await t.eval(() => document.documentURI);
    expect(docURI).eql(`${homePage.url}?&login_succeeded=true`);

    // ヘッダーメニューにログインしたアカウント名が表示されているか
    await t
        .expect(homePage.accountMenuDropdown.exists).eql(true)
        .expect(homePage.accountName.exists).eql(true)
        .expect(homePage.accountName.innerText).eql(accountId);
});
  • fixtureはテストをカテゴライズする機能でテスト実行時にfixtures単位でテストを実行するオプションがあります。
  • testではテストのタイトルとテストコードを記述していきます。
  • Operatorクラスでまとめた認証画面でログインするauthorize(userId, password)を呼びだしてログインを実行しています。
  • ログイン後にURLが切り替わっているかdocURI変数にURLを入れてチェックしています。
  • 最後にexpectを利用してヘッダーモジュールにアカウント名が表示されているかをチェックしています。

まとめ

  • Page Object Design PatternベースにTestCafeでテストを書いてみました。新しいAPIということもあり画面をオブジェクト化しやすい機能が揃っている印象です。
  • テストコードから画面要素と画面操作を分離してコード化することができました。
  • 以前のエントリではChatOpsとの連携も試してみました。E2Eテストを快適に実施と運用していくことを考えるとTestCafeの採用を検討していきたい:coffee:

ソースを公開しています

github.com

関連エントリ

naruto-io.hatenablog.com

WebDriver不要のTestCafeを使ったE2EテストをChatOpsに導入してみた

今回はE2EテストをつくれるTestCafeをつかってみたエントリです。

devexpress.github.io

以前のエントリではSelenideをつかったE2Eテストの紹介をしました。Selenideも特徴がありますがTestCafeも抜群の特徴があります。
TestCafeではテストコードはNodeで書いていきます。そのため広くエンジニアメンバーがE2Eテストを書けるメリットがあります。ES2016 using をつかってテストコードが書かれているためasync/awaitなどの特徴的な構文も使えます。

最大の特徴はテストをリモートで実行できること

TestCafeのプリインストールでテストするブラウザとしてchromesafariが用意されています。ブラウザを指定したテストの実行は次のようになります。

f:id:n_soushi:20170224192652p:plain

実行するとchromesafariが立ち上がり`tests/test.js`のテストが実行されます。
この方法は予めブラウザを指定する方法ですが テストを実行するURLを生成するremoteというオプションがあります。
実行は次のようになります。

f:id:n_soushi:20170224192938p:plain

remote:1としている箇所がリモートで確認するURLの生成数を指定しています。TestCafeは指定されたURL数を生成して返します。テスターはURLをコピーして好きなブラウザにアクセスしてテストを実行できます。ブラウザにアクセスした後の実行結果は次のようになります。

f:id:n_soushi:20170224192759p:plain

TestCafeは自身でテスト用のブラウザを持たずユニークなURLを生成してテスターにブラウザを選ばせることで多種多様なブラウザのテストを可能にします。

このリモート機能を活用してE2EテストをChatOpsに組み込むアイデアを考え実装してみました。

TestCafeのRemoteConnetionをChatOpsに導入する

E2Eテストの運用にChatOpsを導入します。テスターはslackからBot経由でテスト開始を通知してBotとTestCafeが連携します。
それぞれ図にすると次のようになります。

リモートURLをslackで受け取るまで

f:id:n_soushi:20170224170519p:plain

  1. テスターはslackに@bot test web送信する。
  2. Botはイベントを受け取りTestCafeにリモートURLの生成依頼を通知する。
  3. TestCafe(hapi)は生成依頼を受け取りリモートURLを生成してBotへ返す。
  4. BotはリモートURLをルームに送信する。

こんな感じの流れで補足として次のような内容があります。

  • docker-composeでHubotとTestCafeのサビースをつくりコンテナ化した。
  • HubotとTestCafeはHTTP通信でリモートURLのやり取りを行う。
  • TestCafeにはHubotのようなwebhookはないのでhapi.jsで簡易的なAPIを作成した。
  • リモートURLの生成はドキュメントにもあるように簡単に行える。
createTestCafe('localhost', 1337, 1338)
    .then(testcafe => {
        runner = testcafe.createRunner();
        return testcafe.createBrowserConnection();
    })
    .then(remoteConnection => {

        const url = remoteConnection.url
        // ここでslackにリモートURLを送信する
        
        remoteConnection.once('ready', () => {
            runner
                .src('tests/test.js')
                .browsers(remoteConnection)
                .reporter('custom-reporter')
                .run()
                .then(function () { /* ... */ })
                .then(failedCount => { /* ... */ })
                .catch(error => { /* ... */})
        });
    });

※ 抜粋したコードなので詳細はgithubで確認できます

リモートURLを好きなブラウザで開きテスト結果を受け取るまで

f:id:n_soushi:20170224180736p:plain

  1. テスターは好きなブラウザでリモートURLを開く。
  2. TestCafeはリモートURLのアクセスを検知してテストを実行する/テスト結果をHubotに送信する。
  3. Hubotは受け取ったテスト結果をルームに送信する。

こんな感じの流れで補足として次のような内容があります。

  • TestCafeにはReporterクラスをカスタマイズすることでテスト結果のフォーマットを拡張することができる
  • コンソールにテスト結果を表示するのではなくHubotのwebhookに送信したいのでRerporterクラスを拡張した

実際のslackでBotとやり取りしている画面キャプチャ

f:id:n_soushi:20170224183659p:plain

テスターはslackをコントローラーにしてE2Eテストを開始から完了(レポート受信)までワンストップで行えます。

まとめ

  • 当初のTestCafeのリモート機能を利用したE2EテストをChatOpsに組み込むアイデアを実現できた。
  • slackと連動してattachmentをつかってレポート通知をリッチな表示にできた。
  • レポート内容は他にもあるので実運用に必要な情報の精査が必要。
  • TestCafeでテストを書いてみた、というテーマには触れていないので次回はPageModelをベースにTestCafeでE2Eテストを書いてみる。

ソースを公開しています

github.com

nginx-rtmp-module + FFmpeg + HLSで動画配信ができるdocker-composeをつくった

タイトルのdocker-composeをつくっていきます。ローカルで配信確認したいときにシュッと起動できるようにします。
次のような構成でつくりました。

コンテナ構成

f:id:n_soushi:20170217102235p:plain

それぞれのコンテナについてまとめます。

RTMP server

  • nginx-rtmp-moduleをつかってRTMP serverをたてる。

github.com

  • RTMP serverは`rtmp context`でストリームを受け取るapplicationを追加(`application encoder`)。
  • `application encoder`はストリームを受け取るとFFmpegで動画を3つのビットレードにエンコードする。
  • `application encoder`でエンコードした動画をHLS変換する`application hls`へストリームする。
  • `application hls`は変換したm3u8、tsファイルを`/data/hls`に配置する。
  • `/data/hls`はホストで同期されていてStreaming serverからもm3u8、tsファイルを参照できるようにしている。
rtmp {
    server {
        listen 1935;

        application encoder {
            live on;

            exec ffmpeg -i rtmp://localhost:1935/$app/$name
                        -c:a aac -strict -2 -b:a 32k -c:v libx264 -x264opts bitrate=128:vbv-maxrate=128:vbv-bufsize=128 -rtbufsize 100M -bufsize 256k -preset veryfast -f flv rtmp://localhost:1935/hls/${name}_low
                        -c:a aac -strict -2 -b:a 64k -c:v libx264 -x264opts bitrate=256:vbv-maxrate=256:vbv-bufsize=256 -rtbufsize 100M -bufsize 512k -preset veryfast -f flv rtmp://localhost:1935/hls/${name}_mid
                        -c:a aac -strict -2 -b:a 128k -c:v libx264 -x264opts bitrate=512:vbv-maxrate=512:vbv-bufsize=512 -rtbufsize 100M -bufsize 1024k -preset veryfast -f flv rtmp://localhost:1935/hls/${name}_high;
        }

        application hls {
            live on;

            hls on;
            hls_path /data/hls;
            hls_nested on;
            hls_fragment 2s;

            hls_variant _low BANDWIDTH=160000;
            hls_variant _mid BANDWIDTH=320000;
            hls_variant _high  BANDWIDTH=640000;
        }
    }
}

Broadcast server

  • mp4ファイルをFFmpegを使いループ再生させRTMP serverへストリームしている。
  • Broadcast serverはコンテナの1つのためlocalhostではRTMP serverに接続できない。
  • 次のように環境変数を参照するようにしている。
ffmpeg -re -stream_loop -1 -i '/data/broadcast_source.mp4' -f flv "${RTMP_SERVER_URL}/${STREAM_NAME}"
  • docker-composeで環境変数 'RTMP_SERVER_URL'と'STREAM_NAME'を定義している。

Streaming server

  • NginxでHTTP serverをたてる。
  • typesに'm3u8'と'ts'を追加している。
  • rootを'/data'としてRTMP serverと同期しているm3u8とtsファイルにアクセスしている。
server {
        listen 80;

        location /hls {
            types {
                application/vnd.apple.mpegurl m3u8;
                video/mp2t ts;
            }
            root /data;
            add_header Cache-Control no-cache;
            add_header Access-Control-Allow-Origin *;
        }
    }
}

docker-compose

ホストとコンテナでファイルを同期しているのでdindを使いホストもコンテナ化しています。

ホスト側のdocker-compose

version: "3"

services:
  dind_streaming_host:
    container_name: streaming_host
    image: docker:1.13.0-dind
    privileged: true
    ports:
      - 5301:2375
      - 1935:1935
      - 80:80
      - 5000:5000
    volumes:
      - ./video/clouds_over_the_mountain_hd_stock_video.mp4.mp4:/var/container/data/broadcast_source.mp4

コンテナに必要なポートと`Broadcast server`から配信する動画をコンテナに同期しています。

コンテナ群のdocker-compose

version: "3"

services:
  rtmp:
    container_name: rtmp_server
    build: ./container/nginx_rtmp
    ports:
      - 1935:1935
    volumes:
      - /var/container/data:/data
      - /var/container/log/rtmp/nginx/:/var/log/nginx

  stream:
    container_name: stream_server
    build: ./container/nginx_stream
    ports:
      - 80:80
    volumes:
      - /var/container/data:/data
      - /var/container/log/stream/nginx/:/var/log/nginx

  broadcast:
    container_name: broadcast_server
    build: ./container/ffmpeg_broadcast
    depends_on:
      - rtmp
    environment:
      RTMP_SERVER_URL: "rtmp://rtmp:1935/encoder"
      STREAM_NAME: broadcast
    volumes:
      - /var/container/data/:/data

起動方法と動画配信方法

ホストとコンテナでファイルを同期しているのでdindを使いホストもコンテナ化しているので起動にはdindとdirenvが必要です。

docker-composeの起動方法

まずはホスト側の起動から。
もしdindのイメージがなければプルします。

docker pull docker:1.13.0-dind

ホスト側を起動します。

$ cd (path-to 'streaming-host-sample')
$ docker-compose up -d

続いてコンテナ群をまとめているdocker-composeを起動します。

$ cd (path-to 'streaming_host')
direnv: loading .envrc
direnv: export +DOCKER_HOST
$ docker-compose up -d

※ 'streaming_host'ディレクトではdirenvによってディレクトリでは環境変数`DOCKER_HOST`が先に起動したdockerのホストに切り替わっています。

これですべてのコンテナが起動できました。

動画の確認方法

コンテナが起動できたらsafariで`http://localhost/hls/broadcast.m3u8`にアクセスすると`Broadcast server`から配信している動画を再生できます。
今回はプレイヤーを用意せずsafariで直接m3u8を参照しています。

f:id:n_soushi:20170217113815p:plain

サンプルの動画はDISTILLからDLしました。

www.wedistill.io

ライブ配信方法

wirecastなどの配信ツールを使うことでライブ配信ができます。

www.telestream.net

出力先のRTMPサーバに'rtmp://localhost:1935/encoder'を指定します。
ストリームを'live'として配信した場合は`http://localhost/hls/live.m3u8`にアクセスするとライブ配信した動画を確認できます。

まとめ

ソースを公開しています

github.com

mackerelのグラフアノテーションをChatOpsに加えてみた

mackerelからリリースされたグラフアノテーションを追加できるコマンドラインツールをgoで作りました。
作ったコマンドをBot経由で実行できるうようにしてチームのChatOpsに加えていきたいのでbot scriptも作りました。
コマンド作成方法やslack経由で実行できるようにする手順などの紹介エントリです。

グラフアノテーション

mackerelのグラフアノテーションについてはリリースから確認できます。

mackerel.io


ローカルで起動したコンテナのグラフを対象にアノテーションをつけると次のようになりました。
f:id:n_soushi:20170209131211p:plain

このアノテーションの作成をslackのBotにお願いして簡単にアノテーションが追加できるオペレーションを導入します。

コマンドラインツールをつくる

それではmackerelにグラフアノテーションを追加するコマンドラインツールを作っていきます。

クライアントにはmackerel-client-goを使います。

github.com


コマンドラインライブラリはcobraを使います。

github.com

cobraはKubernetesやDocker(distribution)などで採用されています。
cobraを使えば簡単にサブコマンドを作れます。

どんなコマンドを作るか

次のようなコマンドを作ります。

$ graph-annotation post -s 'アノテーションのタイトル' MackerelRole1 MackerelRole2

flagの-sでグラフに表示されるアノテーションのタイトルを指定できます。
パラメータにはrole名を複数指定できます。
チャットからメッセージを入力したいのでflagsを少なくします。

ファイル構成

ファイル構成は次のようになりました。

./cmd
├── graph-annotations
│   └── main.go
├── post.go
└── root.go

次からはcobraでコマンドを作る手順です。

コマンドの概要はroot.goにまとめる

コマンドの概要をroot.goにまとめます。

var RootCmd = &cobra.Command{
	Use:   "graph-annotations",
	Short: "Graph-annotations is a very simple tool for mackerel graph annotations API.",
	Long:  "Complete API documentation is available at https://mackerel.io/api-docs/entry/graph-annotations",
	Run: func(cmd *cobra.Command, args []string) {
	},
}

func init() {
	cobra.OnInitialize()
}
グラフアノテーションの追加はpost.goにまとめる

グラフアノテーションの追加(mackerel-client-goをつかっているところ)はpost.goに実装しています。

func init() {
	postCmd.Flags().StringVarP(&title, "title", "s", "", "required: annotation title")
	postCmd.Flags().StringVar(&description, "description", "", "[optional] annotation details")
	postCmd.Flags().StringVar(&service, "service", os.Getenv("MACKEREL_SERVICE_NAME"), "required: service name, when it's empty value then will use enviroment variable of 'MACKEREL_SERVICE_NAME''")
	RootCmd.AddCommand(postCmd)
}

var title string
var description string
var service string

var postCmd = &cobra.Command{
	Use:   "post",
	Short: "Creating graph annotations",
	Long:  "Tne post command creates graph annotations via graph-annotations API.",
	Example: `graph-annotations post -s 'deploy application' ExampleRole1 ExampleRole2
graph-annotations post --service ExampleService -s 'deploy application' ExampleRole1 ExampleRole2`,
	RunE: func(cmd *cobra.Command, args []string) error {

		// flagのバリデーションを省略しています

		time := time.Now().Unix()
		client := mkr.NewClient(os.Getenv("MACKEREL_API_KEY"))

		annotation := &mkr.GraphAnnotation{
			Service:     service,
			Roles:       args,
			From:        time,
			To:          time,
			Title:       title,
			Description: description,
		}

		err := client.CreateGraphAnnotation(annotation)

		if err != nil {
			return errors.Wrap(err, "client error.")
		}


		fmt.Printf("completed. params title:%s, from:%d to:%d, service:%s, roles:%s", title, time, time, service, args)
		return nil
	},
}

このようにグラフの追加はpost.goにして削除が必要であればdelete.goを追加してサブコマンドを分けることができます。

※ 今回のコマンドではアノテーションをつける時刻範囲は現在時刻にしています。
※ mackerelのサービス名の指定がなければ環境変数の'MACKEREL_SERVICE_NAME'を参照しています。

post.goで整理したコマンドのヘルプ

post.goに整理したコマンドのヘルプは次のように参照できます。

$ graph-annotation help post
Tne post command creates graph annotations via graph-annotations API.

Usage:
  graph-annotations post [flags]

Examples:
graph-annotations post -s 'deploy application' ExampleRole1 ExampleRole2
graph-annotations post --service ExampleService -s 'deploy application' ExampleRole1 ExampleRole2

Flags:
      --description string   [optional] annotation details
      --service string       required: service name, when it's empty value then will use enviroment variable of 'MACKEREL_SERVICE_NAME'' (default "local")
  -s, --title string         required: annotation title

hubotを起動してslackに常駐させたBotにコマンドを実行させる

ローカルでhubotを起動してslackを連携させます。

slackで発行したトークンを環境変数に加えます。

$ export HUBOT_SLACK_TOKEN=<your slack token>

hubotを起動します。

$ cd (path-to-hubot)
$ ./bin/hubot --adapter slack

すべてローカルで実行することでローカルで起動したhubotがトークンを使いslackと連携できます。
f:id:n_soushi:20170209141526p:plain

slackでの利用イメージ

次のようなメッセージをBotに送ると先程つくったコマンドが実行されるようにします。

@localbot note -s 'プッシュ通知を送信しました' web
コマンドを実行するscript

コマンドを実行するscriptは次のように'note '以降の文字列をコマンドに渡して実行しています。

child_process = require 'child_process'

module.exports = (robot) ->
  robot.respond /note\s+(.+)$/, (msg) ->
    option = msg.match[1]

    child_process.exec "graph-annotation post #{option}", (error, stdout, stderr) ->
          if !error
            output = stdout+''
            msg.send output
          else
            output = stderr+''
            msg.send output

slackからメッセージを送ってみる

f:id:n_soushi:20170209142943p:plain

bot経由でグラフアノテーションに追加できました。

mackerelのグラフにも追加したアノテーションが表示されました。
f:id:n_soushi:20170209144516p:plain

まとめ

  • mackerel-client-goが提供されているのでChatOpsに組み込む工程に集中できました。
  • その他の言語でもクライアントライブラリは提供されているのでChatOps以外にもデイリーのバッチ処理実行前や後でアノテーションを追加してログを残す、なんてこともできます。
  • 今回のmackerelの設定はmicroservice毎にroleを分けていることを想定して複数のroleを指定できるようにしました。
  • アノテーションの範囲を時間指定できるのでflagに追加してもよさそうです。今回は簡単にchatでアノテーションをつけられることをコンセプトに時間の指定は省略しました。

ソースを公開しています。

github.com