全部見せます! 「PerlでInfrastructure as Code」

  • 11
    いいね
  • 0
    コメント

以前, 様々なご縁があって, 「WEB+DB PRESS Vol.91」の「Perl Hackers Hub」という連載に, 「PerlでInfrastructure as Code」という記事を掲載させて頂きました.
...ちなみに, 掲載された記事についてはGihyo.jpでご覧いただけます: (1), (2), (3)

こちらの記事では, 「インフラをコードで表す(Infrastructure as Code)」をPerlで実現する際に役立つCPANモジュールの紹介をしていますが, その中で紹介したAWS::CLIWrapperやWebService::Mackerelについては, 会社で開発に従事しているReactioというサービスのデプロイ周りの仕組みを構築する際に活用したモジュールです.

今回は, これらのモジュールを利用して, 実際にどのような処理を行ってWebサービスのデプロイを実現しているのかについて, 紙面で紹介できなかった部分を含めてより詳細にご紹介できれば, と思います.

Reactioのデプロイフロー

まず最初に, Reactioにおけるデプロイの流れを紹介します:

  • Hubotにデプロイを依頼します
  • HubotはJenkinsのデプロイジョブを起動します
    • Jenkinsは各々のジョブに対応したURLを叩くと, そのジョブを起動することができます
    • これを利用して, HubotはJenkinsに設定された「ステージング環境のappをデプロイするジョブ」を起動します
  • Jenkinsは, 「Perlで書かれたデプロイ処理のコード」を実行します
    • 1. Mackerelから, 現在稼働中のEC2インスタンスの情報を取得します
    • 2. 新しいEC2インスタンスを起動します
    • 3. 起動したEC2インスタンスを, ELBに登録します
    • 4. ELBのヘルスチェックをします
    • 5-a. ヘルスチェックで, 新しく起動したインスタンスへのヘルスチェックが成功すれば, 古いインスタンスを退役させます
    • 5-b. ヘルスチェックで, 新しく起動したインスタンスへのヘルスチェックが一定期間を越えても成功しなければ, 新しく起動したインスタンスを退役させます
  • デプロイ完了です
    • お疲れ様でした!

Reactioでは, 「Perlで書かれたデプロイ処理のコード」の中で, PerlからIaaS/SaaSのAPIを操作することで, デプロイに必要な諸々のオペレーションを自動的に実行するようにしている訳です.

この「Perlで書かれたデプロイ処理のコード」は, だいたい次のような感じになっています:

my $running_insntance_ids = get_running_instance_ids();
my $launched_instance_ids = launch_new_instance();

register_elb($launched_instance_ids);

if (check_elb_healthcheck($launched_instance_ids)) {
    retire_instances($running_insntance_ids);
} else {
    retire_instances($launched_instance_ids);
}

...というわけで, 今回はそれぞれの関数, 即ちget_running_instance_ids, launch_new_instance, register_elb, check_elb_healthcheck, そしてretire_instancesが, どのように実装されているかについて説明したいと思います.

今回活用するモジュール達

まず最初に, 今回PerlでInfrastructure as Codeを実践していくにあたって活用する, AWS::CLIWrapperとWebService::Mackerelについて説明します.

AWS::CLIWrapper

Perlについては, AWSの各種サービスを操作するためのSDKが公式から提供されていません.
そこで, CPANには, Amazonが提供するCLIからAWSの各種サービスを操作するコマンドであるawsコマンドをラップした, AWS::CLIWrapperというモジュールが公開されています: https://metacpan.org/pod/AWS::CLIWrapper

ここから紹介するスニペットでは, $awsがAWS::CLIWrapperのオブジェクトになっています. 予め,

use AWS::CLIWrapper;

my $aws = AWS::CLIWrapper->new(
    region => 'ap-northeast-1', # 操作を行うリージョン, 環境変数`AWS_DEFAULT_REGION`でも設定可能
);

のようにして, 準備しておいてください.
更に, 環境変数AWS_ACCESS_KEY_IDにアクセスキーID, AWS_SECRET_ACCESS_KEYにシークレットアクセスキーをセットしておく必要もあります.

WebService::Mackerel

PerlからMackerelを操作するためのライブラリです: https://metacpan.org/pod/WebService::Mackerel
ここから紹介するスニペットでは, $mkrがWebService::Mackerelのオブジェクトになっています. 予め,

use WebService::Mackerel;

my $mkr = WebService::Mackerel->new(
    api_key      => 'key',     # MackerelのAPIキー
    service_name => 'service', # 操作を行うMackerelのサービス名
);

のようにして, 準備しておいてください.

スニペット紹介

注意: エラー処理については, だいぶ無視しています. 実際に使う際には, 適切に処理するようにしましょう.

Mackerelの任意のサービスの任意のロールに紐付いたEC2インスタンスのIDを取得 - get_running_instance_ids

use JSON qw/ decode_json /;

# Mackerelで管理されている`our_service`というサービスにある, `server_role`というロールが設定されたインスタンスのIDを取得する
my $service = 'our_service';
my $role    = 'server_role';

# Mackerelで管理されている全てのホストを取得する
my $all_hosts_monitored_by_mackerel = decode_json($mkr->get_hosts)->{hosts};

# サービス名: $service において, ロール: $role として設定されているホストだけ抜き出す
my @hosts;
for my $host (@{ $all_hosts_monitored_by_mackerel }) {
    for (@{ $host->{roles}->{$service} }) {
        push @hosts, $host if $_ eq $role;
    }
}

# ホストの情報から, EC2のインスタンスIDだけ抜き出して, 返す
my @running_insntance_ids = map { $_->{meta}{cloud}{metadata}{'instance-id'} } @hosts;
return @running_insntance_ids; # => ['i-xxxxxxxx', 'i-yyyyyyyy']

EC2インスタンスの起動 - launch_new_instance

use Path::Tiny;

# 起動するEC2インスタンスで使うAMIのID
my $image_id = 'ami-xxxxxxxx';
# インスタンスを起動するサブネットのID
my $subnets = [qw/subnet-aaaaaaaa subnet-bbbbbbbb/];
# 起動するEC2に紐付けるセキュリティーグループのID
my $security_group_ids = [qw/sg-ssssssss/];
# ユーザーデータのファイル (後述)
my $user_data_file = 'userdata.txt';

my @launched_instance_ids;
for my $subnet (@{ $subnets }) {
    # ユーザーデータのロード
    #   ユーザーデータのファイルを読み込んで, 文字列として`$user_data`に格納
    my $user_data = path( $user_data_file )->slurp_utf8;

    # インスタンスの起動
    #   ここでは1サブネット = 1インスタンスで起動している.
    #   1サブネットで複数のインスタンスを起動したいのであれば,
    #   この処理を繰り返し実行すればよい
    my $instance = $aws->ec2('run-instances' => {
        'image-id'           => $image_id,
        'instance-type'      => $instance_type,
        'subnet-id'          => $subnet,
        'security-group-ids' => $security_group_ids,
        'user-data'          => $user_data,
    });
    push @launched_instance_ids, $instance->{Instances}->[0]->{InstanceId};

    # 起動したEC2インスタンスへのタグ付け
    my $res = $aws->ec2('create-tags' => {
        resources => $instance->{Instances}->[0]->{InstanceId},
        tags      => [
            {
                Key   => 'Name',
                Value => 'instance_name'
            },
            ...
        ],
    });
}

return @launched_instance_ids; # => ['i-xxxxxxxx', 'i-yyyyyyyy']

ユーザーデータについて

EC2では, 「インスタンス起動時に, 任意の処理を実行させたい」という場合, インスタンス起動時に「ユーザーデータ」を渡す, という方法が使えます: http://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/user-data.html

このユーザーデータという仕組みは, 「複数の環境(例えばproductionstaging)で, AMIは同じものを使いたいが, 設定は変えたい」という場合に役立ちます.

例えば, 「AMIにはproduction用のNginxの設定が書かれているが, stagingの時はstagingのための設定に上書きして, EC2インスタンスを起動したい」という場合について考えます.
このような時は, staging用のインスタンスを起動する時に, 次のようなユーザーデータを与えてあげると良いでしょう.

#!/bin/sh
cat > /etc/nginx/conf.service/service.conf <<'EOF';
    ... for staging ...
EOF

こうすれば, Nginxはユーザーデータに記述されたスクリプトが実行された後(つまり, /etc/nginx/conf.service/service.confstaging向けの設定で上書きされた後)に起動します.

Reactioでは, AMIは本番用の設定で用意しておいて, AMIからEC2インスタンスを起動すれば, そのまま本番環境の設定でサービスが動くようにしています.
そして, 本番以外の環境のためにEC2インスタンスを起動する場合, ユーザーデータでNginxやSupervisorなどの設定ファイルを適切に書き換えることによって, 指定した環境の設定でサービスが動くようにしています.

タグ付けについて

起動したEC2インスタンスは, 適切にタグ付けをしておくと, 管理に役立ちます.
上のコードでは, インスタンスの名前を適当に設定していますが, 実際はそのEC2インスタンスのロール(appとかworkerとか...), 環境(productionとかstagingとか...)などを設定しておくと便利でしょう.

インスタンスをELBに登録する - register_elb

# ELBに登録したいEC2インスタンスのID
my $launched_instance_ids = [qw/i-xxxxxxxx i-yyyyyyyy/];
# EC2インスタンスを登録したいELBの名前
my $elb_name = 'service_elb';

$aws->elb('register-instances-with-load-balancer', {
    'load-balancer-name' => $elb_name,
    'instances'          => $launched_instance_ids,
});

ELBのヘルスチェックを確認する - check_elb_healthcheck

# ELBに登録したEC2インスタンスのID
my $launched_instance_ids = [qw/i-xxxxxxxx i-yyyyyyyy/];
# EC2インスタンスを登録したELBの名前
my $elb_name = 'service_elb';

my $try = 0;

# EC2インスタンスの起動にはそれなりに時間がかかるので, 最初に60秒くらい待つ
sleep 60;

while (1) {
    my $instance_status;

    # ELBから, EC2インスタンスのヘルスチェックの結果を取る
    my $res = $aws->elb('describe-instance-health', {
        'load-balancer-name' => $elb_name,
    });

    # データを整形
    for my $instance (@{ $res->{InstanceStates} }) {
        $instance_status->{$instance->{InstanceId}}->{$elb_name} = $instance->{State};
    }

    # $launched_instance_idsのうち,
    # 何台のインスタンスがヘルスチェックに成功しているか(ELB上のステータスが`InService`か)を見る
    my $running_instance_count = 0;
    for my $id (sort @{ $launched_instance_ids }) {
        if ($instance_status->{$id}->{$elb_name} eq 'InService') {
            $running_instance_count++ ;
        }
    }

    # 起動したインスタンス数 = ヘルスチェックが成功したインスタンス数, であれば起動完了として`1`を返す
    return 1 if $running_instance_count == (scalar @{ $launched_instance_ids }) * (scalar @{ $elbs });

    # 60回までリトライする, それでもヘルスチェックが成功しないなら失敗として`0`を返す
    return 0 if $try >= 60;

    # トライ回数をインクリメント
    $try++;

    # 5秒後に再びヘルスチェックする
    sleep 5;
}

インスタンスを退役させる - retire_instances

# 退役させたいインスタンスのID
my $instance_ids = [qw/i-xxxxxxxx i-yyyyyyyy/];

$aws->ec2('terminate-instances', {
    'instance-ids' => $instance_ids,
});

まとめ

だいぶさっくりではありますが, 「PerlでInfrastructure as Code」の記事で紹介した2つのモジュールを, Reactioというサービスにおいて実際どのように利用しているのか? についてご紹介しました.

この辺りの「IaaSの操作」については, 例えばTerraformなどを使うという手もあります.
しかしながら, 今回ご紹介したように, 適切にライブラリを活用していけば, 「インフラの操作」を「APIの操作」として, 使い慣れた言語で記述することが出来るようになります.

...記事の中でも述べているのですが, Infrastructure as Codeについては, サービスやチームの成長に追随しなければならないので, チーム一丸で取り組んでいくことが重要です.
そのため, 「使い慣れた言語(ここでは, Perl)」で「インフラをコードで表す」ことが出来るという選択肢について把握しておくことは, 非常に有意義だと思っています.

この記事が, 少しでもご参考になれば幸いです.