PHP 製のデータベースのスキーマ定義を差分で適用するツール「dbdatool」

データベースのスキーマ定義の変更、いわゆるデータベースのマイグレーションついて、スキーマ定義の差分を SQL とかそれ用の DSL とかで作成し、リポジトリ管理してマイグレーションツールで実環境に適応するのが多いと思います。

この方法はわかりやすくて良いですが、スキーマの変更が頻繁だと細々とした変更が差分ファイルとして積み上がっていくのでかなり煩雑です。

一方で、最新のスキーマ定義だけをリポジトリ管理し、実際のデータベースと比較してその差分だけを適用するようにすれば、差分を管理する必要は無くなって、例えばテーブルに列を増やしたければ最新のスキーマ定義ファイルに修正を加えるだけで済みます。

そのようなマイグレーションツールは Perl や Ruby では GitDDLRidgepole などがあるようです。PHP なら Doctrine でそれっぽいことができるようなのですが Doctrine 特有の制約が多そうだったので、dbdatool というツールを作成しました(いまのところ MySQL 専用)。

概要

スキーマ定義は生の SQL で CREATE TABLE などの DDL が管理されている想定です。ただし、ツール自体に生の SQL を読み込み&解析するような機能は無く、できることは基本的には下記のみです。

  • 稼働中のデータベースからスキーマ定義ファイルを独自の JSON 形式で出力する
  • スキーマ定義ファイル(↑で出力したファイル)とデータベースを比較して差分を ALTER などで出力する
  • ↑の差分をデータベースに適用する

あるいは、次のように PDO の DSN の形式で、2つのデータベースのスキーマ定義の差分を表示したり、差分を適用したりもできます。

php dbdatool.phar diff \
    "mysql:host=192.0.2.100;port=3306;dbname=test;charset=utf8:user:password" \
    "mysql:host=192.0.2.200;port=3306;dbname=test;charset=utf8:user:password"

使い方の例

dbdatool のDB接続情報の設定ファイルを作成します。接続情報は環境変数から得るのがポイントです。

<?php
$host = getenv('DB_HOST');
$port = getenv('DB_PORT');
$dbname = getenv('DB_DATABASE');
$username = getenv('DB_USERNAME');
$password = getenv('DB_PASSWORD');
return [
    'dsn' => "mysql:host=$host;port=$port;dbname=$dbname;charset=utf8",
    'username' => $username,
    'password' => $password,
];

dotenv 使ってるなら先頭に↓みたいなのが必要です。

<?php
require __DIR__ . '/../vendor/autoload.php';
(new \Dotenv\Dotenv(dirname(__DIR__)))->load();

この設定ファイルのパスを composer.json に追記します。composer.json にこのように書いておけば dbdatool の CLI によって自動的に読まれます。

{
    "extra": {
        "dbdatool-config": ["database/config.php"]
    }
}

ここまでが事前の準備です。

次に、スキーマ定義に変更があるときは生の SQL で管理している DDL を修正した後、手元の適当なデータベースに mysql コマンドでインポートします。たいてい開発者の手元には開発用とテスト用で2つのデータベースがあるので、テスト用のデータベースにインポートします。

cat *.sql | mysql hoge_test -v

テスト用のデータベースからスキーマ定義ファイルをダンプします。dbdatool の設定ファイルでDB接続情報を環境変数から得るようにしていたので、実行時に環境変数を指定すればテスト用のデータベースからダンプできます。

MYSQL_DATABASE=hoge_test php dbdatool.phar dump > schema.json

スキーマ定義ファイルと開発用のデータベースとの差分を表示・確認して、問題なければ差分を適用します。

php dbdatool.phar diff schema.json
php dbdatool.phar apply schema.json

Git リポジトリに追加・コミット・プッシュします。

git add .
git commit -m 'Fix database schema'
git push

他の開発者は git pull の後にスキーマ定義の変更を適用できます。

git pull -r
php dbdatool.phar diff schema.json
php dbdatool.phar apply schema.json

実環境へも schema.json をアプリのコードと一緒にデプロイすれば差分で適用できます。

特徴

スキーマ定義は生の SQL の DDL で管理したかったのですが、生の SQL と実際のデータベースとを直接比較して差分を導出しようとすると SQL のパーサーが必要になってしまいます。そこまで作り込みたくなかったので、生の SQL は一旦適当なデータベースにインポートした上で INFORMATION_SCHEMA から必要な情報を取得してスキーマ定義ファイルとして保存&リポジトリ管理することにしました。

スキーマ定義ファイル(schema.json)は JSON なのでやろうと思えば手で編集できますが、かなり雑な比較で差分を導出しているので、手書きだといろいろ不都合が生じることがあります。例えば tinyintboolean を同一視していないので schema.jsonboolean と書いてしまうと apply しても差分がなくなりません(DB上は tinyint になっているため)。

なお、パーティショニング、ビュー、トリガ、ストアド、などはサポートしていません。サポートしているのは下記のみです。

  • テーブル(生成列も含む、パーティショニングは未サポート)
  • インデックス
  • 外部キー制約

さいごに

後に schemalex とか sqldef とかを知って、バイナリポンで動くならこういうので良いかとも思ったのですが、既に dbdatool をプロジェクトで使い始めていたので、自分でメンテして自由が利かせられる dbdatool を暫くは使っていこうと思ってます。

CloudWatch Agent を試す

CloudWatch の EC2 のメトリクスだとメモリ使用率やディスク使用率が取れないので別途 Amazon CloudWatch Monitoring Scripts でカスタムメトリクスとして取得したりしていましたが、今日日は CloudWatch Agent を使えばいいだろうので素振り。

残骸はこちら

IAM ロール

EC2 インスタンスで CloudWatch Agent を実行するためには IAM ロールをインスタンスプロファイルで付与する必要があります。ポリシーは AWS 管理の arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy で OK です。

注意点として、CloudWatch Agent は設定ファイルをを SSM Parameter store から取得することができるのですが、このポリシーを使う場合は AmazonCloudWatch- という名前で始まるパラメータ名で設定ファイルを保存する必要があります(ポリシーでそのように制限されている)。

CloudWatch Agent のセットアップ

手作業での手順は次のとおり。これに相当する手順を Terraform で EC2 のユーザーデータに入れてます。

# amazon-cloudwatch-agent のインストール
sudo yum install https://s3.amazonaws.com/amazoncloudwatch-agent/amazon_linux/amd64/latest/amazon-cloudwatch-agent.rpm

# 設定ファイルを作成
sudo vim /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json

# 設定ファイルをインポートしてエージェントを開始
sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 \
    -c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json -s

ユーザーガイドでは SSM (AWS Systems Manager) で CloudWatch Agent をセットアップしていますが、そうはせずに Terraform で EC2 インスタンスのユーザーデータに必要な情報を書き込んで cloud-init でセットアップしました。

設定ファイル

前述の手作業の手順の最後のコマンドは、amazon-cloudwatch-agent.json を TOML 形式の設定ファイルに変換して amazon-cloudwatch-agent.toml に保存した後にエージェントを開始します。

はじめから amazon-cloudwatch-agent.toml を作成して配置すればいいような気もしますが amazon-cloudwatch-agent.jsonamazon-cloudwatch-agent.toml とを比べてみると単にフォーマットが変換されただけではなくいろいろ違いがあるので、設定を容易にするために変換をかますようになっているのかもしれません。

また、amazon-cloudwatch-agent.toml には InstanceId がベタ書きされているので(CloudWatch Logs へのログ転送を設定しているなら)、あらかじめ CloudWatch Agent がセットアップされた AMI を作成して使う場合でも amazon-cloudwatch-agent-ctl コマンドは必要です。

設定ファイルの書き方は下記を参考にします。

なお、amazon-cloudwatch-agent-config-wizard という TUI のウィザード形式で設定ファイルを作成するツールがあるので、このツールが作成したファイルを編集して使用すると良いかもしれません。

収集できるメトリクス

収集できるメトリクスの一覧は次のとおりです。

また、procstat で特定のプロセスのメトリクスを取得したりもできます。

これら以外に StatsDcollectd が収集したメトリクスを CloudWatch に記録することもできるようです。

メトリクスに固有のディメンジョン

メトリクスによっては固有のディメンジョンが設けられていることがあります。

例えば cpu であればコアごとまたはトータルを示すディメンジョンが、disk なら、デバイス・ファイルシステム・マウントポイント、などがディメンジョンになります。

要するに、CPU はコアごとのメトリクスまたはトータルのメトリクスが、ディスクならデバイス&ファイルシステム&マウントポイントごとのメトリクスが記録される、ということです。

メトリクス(のセクション)ごとに詳細な設定が出来ることもあります。例えば disk の場合、対象となるマウントポイント、除外するファイルシステム、デバイスをディメジョンに含めるか、などが指定できます。

{
  "metrics": {
    "metrics_collected": {
      "disk": {
        "resources": [
            "/"
        ],
        "measurement": [
          "disk_used_percent"
        ],
        "ignore_file_system_types": [
          "rootfs",
          "tmpfs",
          "devtmpfs"
        ],
        "drop_device": true
      }
    }
  }
}

append_dimensions

前述のディメンジョンとは別に amazon-cloudwatch-agent.jsonmetrics.append_dimensions で追加のディメンジョンが指定できます。ただし、下記の4つのいずれかしか指定できないようです。

{
  "metrics": {
    "append_dimensions": {
      "AutoScalingGroupName": "${aws:AutoScalingGroupName}",
      "ImageId": "${aws:ImageId}",
      "InstanceId": "${aws:InstanceId}",
      "InstanceType": "${aws:InstanceType}"
    }
  }
}

キーと値のセットはこれに一致する必要があります(値が存在する意味がないような・・謎い)。

指定した項目がメトリクスのディメンジョンに追加されます。全部指定すればひとつのメトリクスにこれら4つのディメンジョンがすべて追加されます。それぞれのディメンジョンを持つ別のメトリクスになるというわけではありません。

aggregation_dimensions

aggregation_dimensions でメトリクスを集約するディメンジョンを指定できます。例えば AutoScalingGroupName で集約すれば AutoScalingGroupName のみをディメンジョンに持つメトリクスを記録できます。要するに AutoScalingGroupName ごとに集約したメトリクスになります。

AutoScaling の条件として使うなら AutoScalingGroupName で集約する必要がありますね。

ログ

メトリクスだけではなく、ログファイルを監視して CloudWatch Logs に送ることもできます。

さいごに

セットアップも簡単なのでリソース監視に CloudWatch をメインに据えるならとりあえず使っとけば良いと思います。


CloudWatch Agent とは直接関係無いですが・・・複数のディメンジョンを持つメトリクスに対して一部のディメンジョンでのみ集計した結果に対して CloudWatch Alarm を設定したりはできないんですね。

例えば AutoScalingGroupName と InstanceId の2つのディメンジョンを持つメトリクスに対して、AutoScalingGroupName を条件に複数の InstanceId を合計したメトリクスに対して CloudWatch Alarm を設定することができません。

Metric Math で近いことならできそうですが・・あらかじめ集計する複数のメトリクスをリストしておく必要があるので AutoScaling でインスタンスが増えたり減ったりする状況ではダメそうです。

ので、やりたければ AutoScalingGroupName だけをディメンジョンに持つメトリクスを別に記録する必要があります。

AWS/EC2 名前空間のメトリクスでは、同じ CPUUtilization でも InstanceId ごとや AutoScalingGroupName ごとのメトリクスに CloudWatch Alarm が設定できたので、複数のメトリクスの集計に対して CloudWatch Alarm を設定することができるような気がしていたのですが、単に同じ CPUUtilization で InstanceId のみをディメンジョンに持つメトリクスと AutoScalingGroupName をディメンジョンに持つメトリクスが別々に記録されているだけでした。

Prometheus なら rate(node_cpu{mode="system",group="mygroup"}[5m]) とかでサッとできるので、CloudWatch Alarm のちょっと使いにくいなーと感じるところです。

RancherOS で KVM+libvirt に固定IPで Docker ホストを作るメモ

KVM+libvirt な仮想化ホストに、ブリッジ I/F で固定IPな Docker ホストを RancherOS で作るメモ。

# RancherOS の ISO をダウンロード
wget https://releases.rancher.com/os/latest/rancheros.iso

# SSH鍵をダウンロード
curl -s https://github.com/ngyuki.keys -o authorized_keys

# ConfigDrive のディレクトリを準備
mkdir -p configdrive/openstack/latest

# ConfigDrive の user_data を作成
vim configdrive/openstack/latest/user_data
#cloud-config

ssh_authorized_keys:
$(sed 's/^/  - /' authorized_keys)

rancher:
  network:
    dns:
      nameservers:
        - 192.168.0.1
    interfaces:
      eth0:
        address: 192.168.0.100/24
        gateway: 192.168.0.1
        dhcp: false

  state:
    formatzero: true
    autoformat:
      - /dev/vda
# ConfigDrive の ISO を作成
mkisofs -R -V config-2 -o configdrive.iso configdrive

# 永続データのディスクイメージを作成
echo -n "boot2docker, please format-me" > data.img
truncate -s 1G data.img

# ゲストを作成
virt-install \
  --import \
  --name rancheros \
  --hvm \
  --virt-type kvm \
  --ram 2048 \
  --vcpus 1 \
  --arch x86_64 \
  --os-type linux \
  --boot cdrom \
  --disk "$PWD/data.img,device=disk,bus=virtio,cache=writeback" \
  --disk "$PWD/rancheros.iso,device=cdrom" \
  --disk "$PWD/configdrive.iso,device=cdrom" \
  --network network=front,model=virtio \
  --graphics none \
  --serial pty \
  --console pty

動作確認します。

ssh docker@192.168.0.100 docker run --rm hello-world
ssh docker@192.168.0.100 docker run -d -p 80:80 nginx
curl http://192.168.0.100/

補足とか

cloud-init

RancherOS は cloud-init で初期設定が可能なのですが、cloud-init がそのまま使用されているわけではなく、独自のこれ https://github.com/rancher/os/tree/master/config/cloudinit が使用されているようです(CoreOS からのフォーク?)。

なので DataSources も一部しかサポートしていません。NoCloud がダメっぽかったので ConfigDrive で初期設定しています。

autoformat

user_data で下記の通りにしておくと /dev/vda が永続データ用の領域として自動でフォーマットされるのですが、そのディスクの先頭に boot2docker, please format-me が記述されている必要があります。

rancher:
  state:
    formatzero: true
    autoformat:
      - /dev/vda

なお、この方法でフォーマットすると /dev/vda にはパーティションテーブル無しで直接ファイルシステムが作成されます。

パーティションが切られてたほうが良いなら、次のようにあらかじめディスクイメージにパーティションを作成しておけば OK です。

truncate -s 1G data.img
virt-format --format=raw --partition=gpt --filesystem=ext4 --label=B2D_STATE -a data.img

boot2docker で KVM+libvirt に固定IPで docker ホストを作るメモ

KVM+libvirt な仮想化ホストに、ブリッジ I/F で固定IPな docker ホストを boot2docker で作る試行錯誤のメモ。

なお boot2docker は新しい Docker のリリースや kernel の更新以外では更新されないメンテナンスモードになっているようです。

userdata.tar で SSH 鍵を配置

boot2docker は最初のブート時に先頭が boot2docker, please format-me という文字列で始まっているブロックデバイスを tar ファイルとして取り出して、ブートの都度 /home/docker に展開します。

tar を取り出した後のそのデバイスは永続データの領域として自動的にフォーマットされます。

そのようなブロックデバイスのためのディスクイメージはは次のように作成できます。

mkdir -p .ssh
chmod 700 .ssh/
curl https://github.com/ngyuki.keys -o .ssh/authorized_keys
chmod 600 .ssh/authorized_keys
echo "boot2docker, please format-me" > "boot2docker, please format-me"
tar cvf userdata.img "boot2docker, please format-me" .ssh
truncate -s 1G userdata.img
qemu-img convert -f raw -O qcow2 userdata.img userdata.qcow2

docker-machine はこの仕組を用いて authorized_keys を配置しているようですが、tar ファイルは /home/docker に展開されるだけなので固定IPを付与するような処理は行なえません。

bootsync.sh でブート時に固定IPを付与

boot2docker はブート時に /var/lib/boot2docker/bootsync.sh/var/lib/boot2docker/bootlocal.sh があればそれを sh で実行します。

/var/lib/boot2docker/ は永続データの領域なので、/var/lib/boot2docker/bootsync.sh に次のように書いておけばブートの都度、固定IPが付与されます。

killall udhcpc
sleep 1
ip addr flush dev eth0
ip addr add 192.168.0.100/24 dev eth0
ip route add default via 192.168.0.1 dev eth0
echo nameserver 192.168.0.1 > /etc/resolv.conf

しかし、初回ブート時にこのファイルを作成する術がありません。コンソールで作業すればいいだけですが・・

boot2docker-data

boot2docker はブート時に boot2docker-data というラベルのついたパーティションを探して、見つかればそれを永続データの領域としてマウントします。見つからなければ boot2docker, please format-me という文字列で始まっているブロックデバイス、またはパーティション未作成なブロックデバイスを自動的にフォーマットしてマウントします。

普通は初回ブート時に自動フォーマットされるときに、このラベルがついたファイルシステムとしてフォーマットされるのですが、あらかじめフォーマット済で boot2docker-data というラベルのついたパーディションを含むディスクファイルを用意すれば、任意のカスタマイズされた永続データ領域を初回ブート時から使用できます。

boot2dockerswap

boot2docker はブート時に boot2dockerswap というラベルのついたパーティションを探して、見つかればそれをスワップとして使います。見つからなくても警告が出力されるだけで起動はするようです。

普通は初回ブート時に自動フォーマットされるときに、永続データ用のパーティションをフォーマットするついでにこのラベルの付いたスワップパーティションも作成されますが、あらかじめ boot2dockerswap というラベルの付いたスワップパーティションを含むディスクファイルを用意すれば、自動フォーマットが行われなくてもスワップが有効になります。

やってみる

# 固定IPのためのスクリプトを作成
mkdir -p ./rootfs/var/lib/boot2docker/
cat <<'EOS'> ./rootfs/var/lib/boot2docker/bootsync.sh
killall udhcpc
sleep 1
ip addr flush dev eth0
ip addr add 192.168.0.100/24 dev eth0
ip route add default via 192.168.0.1 dev eth0
echo nameserver 192.168.0.1 > /etc/resolv.conf
EOS
chmod -x ./rootfs/var/lib/boot2docker/bootsync.sh

# SSH公開鍵を含む userdata.tar を作成
mkdir -p ./userdata/.ssh/
curl -s https://github.com/ngyuki.keys -o ./userdata/.ssh/authorized_keys
chmod 700 ./userdata/.ssh/
chmod 600 ./userdata/.ssh/authorized_keys
tar cvf ./rootfs/var/lib/boot2docker/userdata.tar -C ./userdata/ .ssh/

# ↑が保持ぞんされたファイルシステムを含むディスクファイルを作成
virt-make-fs --format=raw --partition=gpt --size=1G --type=ext4 --label=boot2docker-data ./rootfs/ data.img

# スワップのディスクファイルを作成
truncate -s 1G swap.img
parted -s -a optimal swap.img -- mklabel gpt mkpart primary 1 -1
guestfish -a swap.img run : mkswap /dev/sda1 label:boot2dockerswap

# 確認
virt-df -h -a data.img -a swap.img
#=> Filesystem                                Size       Used  Available  Use%
#=> data.img+:/dev/sda1                       992M       2.5M       922M    1%

# 確認
virt-filesystems --all --long -h -a data.img -a swap.img
#=> Name       Type        VFS   Label             MBR  Size   Parent
#=> /dev/sda1  filesystem  ext4  boot2docker-disk  -    1.0G   -
#=> /dev/sdb1  filesystem  swap  boot2dockerswap   -    1022M  -
#=> /dev/sda1  partition   -     -                 -    1.0G   /dev/sda
#=> /dev/sdb1  partition   -     -                 -    1022M  /dev/sdb
#=> /dev/sda   device      -     -                 -    1.0G   -
#=> /dev/sdb   device      -     -                 -    1.0G   -

# boot2docker.iso をダウンロード
wget https://github.com/boot2docker/boot2docker/releases/download/v18.09.1/boot2docker.iso

# ゲストを作成
virt-install \
  --import \
  --name boot2docker \
  --hvm \
  --virt-type kvm \
  --ram 1024 \
  --vcpus 1 \
  --arch x86_64 \
  --os-type linux \
  --boot cdrom \
  --disk "$PWD/data.img,device=disk,bus=virtio,cache=writeback" \
  --disk "$PWD/swap.img,device=disk,bus=virtio,cache=none" \
  --disk "$PWD/boot2docker.iso,device=cdrom" \
  --network network=default,model=virtio \
  --graphics none \
  --serial pty \
  --console pty

動作確認します。

env DOCKER_HOST=ssh://docker@192.168.0.100 docker run --rm hello-world
env DOCKER_HOST=ssh://docker@192.168.0.100 docker run -d -p 80:80 nginx
curl http://192.168.0.100/

さいごに

クラスタ化しないシングルの Docker 環境をサッと作れるように試行錯誤したのですが、boot2docker.iso

On the other hand, the boot2docker distribution (as in, boot2docker.iso) is in "maintenance mode".

とのことらしいので、別の Docker ホスト用の軽量 Linux を使うのが良いですかね。。

ただ、Container Linux (CoreOS) や Atomic Host はクラスタにする前提な気がして敷居が高く感じたので、RancherOS とかがいいんでしょうかね。

Amazon EC2 Auto Scaling を Terraform で素振り

Amazon EC2 Auto Scaling を ユーザーガイド を読みながら Terraform で素振りしたメモ。残骸はこちら

Launch Configuration と Launch Template

Auto Scaling で起動する EC2 インスタンスを定義する方法は下記の複数があります。

  • Launch Configuration
  • Launch Template

Launch Configuration より Launch Template のほうが後発で、以下のような違いがあります。

  • Launch Configuration は Auto Scaling 専用なのに対して Launch Template は他の用途にも使用できる
    • Launch Template から直で EC2 インスタンスを作成したりできる
  • Launch Template はバージョン管理されるので更新すると新たにバージョンが作成される
    • Launch Configuration はバージョン管理されないので更新時に新旧維持するためには別名で作成するしか無い

また、Launch Template でしかできないことがいくつかあります。基本的に Launch Template を使っていれば良さそうです。

ルートデバイス

Launch Configuration なら Terraform で root_block_device でルートデバイスが指定できますが Launch Template だと block_device_mappings でデバイス名を明示的に指定する必要があります。

最初 CentOS 向けに /dev/sda1 で Launch Template を作成していましたが、AMI を Amazon Linux 2 ベースに変更したところインスタンスが開始できなくなりました。Amazon Linux 2 だとルートデバイスが /dev/xvda になるためです。

トラブルを避けるために Launch Template で block_device_mappings でルートデバイスを指定していろいろ設定するのはやめて、あらかじめカスタムも AMI にブロックデバイスの情報を程よく刻んでおくのが良いかもしれません。もっともそんな間違いは滅多に無いだろうし、間違えたとしても直ぐに気づくだろうので気にしなくても良いかもしれません。

下記のように AMI の block_device_mappings を参照すれば良いかも。

data "aws_ami" "app" {

  // snip

}

resource "aws_launch_template" "app" {

  // snip

  block_device_mappings {
    device_name = "${lookup(data.aws_ami.app.block_device_mappings[0], "device_name")}"

    ebs {
      volume_size           = 40
      volume_type           = "gp2"
      delete_on_termination = true
    }
  }
}

Blue/Green Deployment

無停止でインスタンスの AMI を更新する方法はいくつか考えられます。

  • ELB までをワンセットにした Blue/Green Deployment で DNS を切り替える
  • AutoScaling Group までをワンセットにした Blue/Green Deployment で ELB を切り替える
  • Launch Configuration/Template までをワンセットにして AutoScaling Group 内のインスタンスを Rolling Update

最後の方法は Terraform 単体ではたぶん無理です。DNS を切り替える方法は TTL の影響を受けるし Route53 も必要なので、ELB を切り替える方法を試しました。

AutoScaling Group が参照する Launch Configuration を変更したとしても起動済の EC2 インスタンスには影響しません。その AutoScaling Group で新しく起動するインスタンスでのみ新しい Launch Configuration で起動します。なので AMI 更新時に AutoScaling Group はリソースが再作成されるようにする必要があります。これは AutoScaling Group の name に Launch Configuration の name を含めればよいです、後述の通り Launch Configuration の変更時には別名で再作成させるため、Launch Configuration の name が変われば AutoScaling Group の name も変わって、AutoScaling Group も再作成になります。

AutoScaling Group が再作成されるとき、デフォだと「古いリソースの削除→新しいリソースの作成」の順番で処理されます。これだと再作成するときに一時的にインスタンスが全滅してしまうので lifecyclecreate_before_destroy = true を指定して「新しいリソースの作成→古いリソースの削除」の順番で処理されるようにします。

Launch Configuration は作成後に更新ができず、変更があるときはリソースの再作成が必要になります。再作成時に lifecyclecreate_before_destroy = true を指定していると一時的に新旧のリソースが同時に存在するため名前の重服を避けるための name_prefix を設定します。これで Launch Configuration の name は Terraform がタイムスタンプを元に自動生成します。

AutoScaling Group と ELB のターゲットグループは aws_autoscaling_grouptarget_group_arns で行う必要があります。aws_autoscaling_attachment リソースでも紐付けできますが、aws_autoscaling_attachment だとデプロイ時に一時的に 503 になる期間が生じます。aws_autoscaling_attachment だと新しい AutoScaling Group が作成された後に ELB にアタッチされるため、ヘルスチェックのタイプが作成直後は EC2 になります。そのため EC2 インスタンスのステータスが OK になった時点で 古い AutoScaling Group が ELB からデタッチされるため、その後、新しい AutoScaling Group を ELB にアタッチしして新しい EC2 インスタンスで Web サーバの準備が終わって ELB のヘルスチェックが完了するまでの期間がダウンタイムになります。

まとめると、次のようなリソース定義になります。

resource "aws_launch_configuration" "app" {
  name_prefix     = "hello-asg-app-"

  // snip

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_autoscaling_group" "app" {

  name                 = "${aws_launch_configuration.app.name}"
  launch_configuration = "${aws_launch_configuration.app.name}"

  // snip

  health_check_type   = "ELB"
  target_group_arns = ["${aws_lb_target_group.app.arn}"]

  lifecycle {
    create_before_destroy = true
  }
}

なお、この方法だとひとつの ELB に新旧両方の AutoScaling Group がアタッチされている期間があるため、ELB からの転送先に一時的に新旧が混在します。

Launch Template を使う場合は Launch Template がバージョニングをサポートしているので、aws_launch_configurationname_prefixcreate_before_destroy のような仕込みは不要です。ただし Launch Template の更新時に AutoScaling Group の再作成を行わせる必要があるのは変わらないので、AutoScaling Group の name には Launch Template のバージョンを含めます。

resource "aws_autoscaling_group" "app" {

  name = "hello-asg-app-${aws_launch_template.app.latest_version}"

  launch_template = {
    id      = "${aws_launch_template.app.id}"
    version = "${aws_launch_template.app.latest_version}"
  }

  health_check_type   = "ELB"
  target_group_arns = ["${aws_lb_target_group.app.arn}"]

  lifecycle {
    create_before_destroy = true
  }
}

スケーリングポリシー

メトリクスに基づいた、いわゆるオートスケーリングの設定には下記の3つのポリシーが使用できます。

  • ターゲット追跡スケーリング
  • ステップスケーリング
  • 簡易スケーリング

ターゲット追跡スケーリングが一番後発です。基本的にターゲット追跡スケーリングを使っておけば OK です。

ターゲット追跡スケーリング

指定したメトリクスが指定した値に近づくように自動的にスケーリングします。メトリクスには以下の事前定義されたメトリクスが指定できます。

  • ターゲット別の Application Load Balancer リクエストの数
  • CPU の平均使用率
  • 平均ネットワーク入力 (バイト)
  • 平均ネットワーク出力 (バイト)

スケーリングポリシーを設定すると、マネジメントコンソールからは変更や削除ができない CloudWatch アラームが自動で作成されます。例えば「CPU の平均使用率」を 40 に設定すると次のような CloudWatch アラームが自動的に作成されます。

  • CPUUtilization > 40 for 3 datapoints within 3 minutes
  • CPUUtilization < 36 for 15 datapoints within 15 minutes

この2つの閾値に収まるようにインスタンス数が AutoScaling Group の Min と Max の間で自動的に調整されます。さらに CPUUtilization < 36 のアラームはCPU使用率がほぼゼロのまま放置しているとさらに低い値に自動的に変化したので、スケーリング閾値のアラーム自体も自動で調整されるようです。

マネジメントコンソールからは事前定義済の前述の4つのメトリクスしか選択できませんが、任意のメトリクスが指定できます。ただし、AutoScaling Group のインスタンス数に対して逆相関(インスタンスが増えれば値が減る)なメトリクスでなければ意味がありません。

Terraform で事前定義済のメトリクスを指定するには次のようにします。

resource "aws_autoscaling_policy" "cpu" {
  autoscaling_group_name = "${aws_autoscaling_group.app.name}"
  name                   = "cpu"
  policy_type            = "TargetTrackingScaling"

  target_tracking_configuration {
    predefined_metric_specification {
      predefined_metric_type = "ASGAverageCPUUtilization"
    }

    target_value = 40.0
  }
}

同じメトリクスをカスタムメトリクスとして指定するなら以下のようになります。

resource "aws_autoscaling_policy" "cpu" {
  autoscaling_group_name = "${aws_autoscaling_group.app.name}"
  name                   = "cpu"
  policy_type            = "TargetTrackingScaling"

  target_tracking_configuration {
    customized_metric_specification {
      namespace   = "AWS/EC2"
      metric_name = "CPUUtilization"
      statistic   = "Average"

      metric_dimension {
        name  = "AutoScalingGroupName"
        value = "${aws_autoscaling_group.app.name}"
      }
    }

    target_value = 40.0
  }
}

簡易スケーリングとステップスケーリング

簡易スケーリングとステップスケーリングは、スケーリングポリシーと、スケーリングポリシーをアクションとして呼び出す CloudWatch アラームを別々に作成する必要があります。

簡易スケーリングとステップスケーリングはよく似ていて、簡易スケーリングがスケーリングの調整値(インスタンスをどれだけ追加するか、とか)が1段階しか設定できないのに対して、ステップスケーリングならアラームを超過した量に応じて、段階的にスケーリング調整値を指定できます(閾値を超えたら 4 台追加するけれども 20% 超えてるなら一気に 10 台追加する、とか)。

なお、ステップスケーリングのみ metric_aggregation_type が指定できます、が、何に使われるものなのかわかりません・・・。対応するアラームの statistic と同じものを指定しておけばよいのかな? なお、API リファレンスでは metric_aggregation_type には Minimum/Maximum/Average のいずれかしか指定できないことになっているのですが実際には Sum も指定できるようです。SampleCount は指定できないようです。

スケジュールに基づくスケーリング

指定された間隔や cron 式による時刻に AutoScaling Group の Desired Capacity や Min Size や Max Size を変更します。特定の曜日や特定の時間帯だけスケールインさせたりできます。

詳細モニタリング

スケーリングポリシーを設定するなら、詳細モニタリングを有効にして1分ごとにメトリクスが記録されるようにし、迅速にスケーリングできるようにしておくと良いです。

resource "aws_launch_template" "app" {

  // snip

  monitoring {
    enabled = true
  }
}

クールダウンとウォームアップ

クールダウンは手動スケーリングや簡易スケーリングポリシーで適用される。クールダウン期間中は簡易スケーリングポリシーによるスケーリングが発生しなくなる。

手動スケーリングではクールダウンをなしにするか Group に設定されたデフォルトのクールダウン期間を用いるかを選択できる。簡易スケーリングポリシーではスケーリング固有のクールダウン期間を指定するか、Group に設定されたデフォルトのクールダウン期間を用いるかを選択できる。

ウォームアップはターゲット追跡スケーリングとステップスケーリングで適用される。ウォームアップ期間中のインスタンスはスケールアウトの調整値を適用するときの元数として使用されない。例えば、現在5台のインスタンスが起動中で、うち2台がウォームアップ期間だとすると、このときにインスタンスを3台追加する調整値のスケーリングポリシーが実行されたとしても、2台がウォームアップ中なので1台しか追加されない。

ライフサイクルフック

ELB にアタッチされる AutoScaling Group の場合は前述の通り ELB の転送先の切り替えによる Blue/Green Deployment で無停止でアップデートできます。

Terraform が AutoScaling Group を再作成するとき、Terraform は新しい AutoScaling Group が利用可能になるまで待機してから古い AutoScaling Group を削除します。AutoScaling Group のヘルスチェックタイプを ELB にすれば AutoScaling Group が利用可能かの判断は ELB のヘルスチェックが Healthy となったときになるため、Terraform が AutoScaling Group を再作成するとき、新しいインスタンスが ELB で Healthy になるまで Terraform は待機します。

一方、ELB が絡まないワーカーのようなインスタンスだと前述のような ELB のヘルスチェックが使えないため簡単ではありません。EC2 インスタンスのステータスが OK となった時点で Terraform が新しい AutoScaling Group を利用可能として待機が終わってしまうためです。

ライフサイクルフックを使えばインスタンスのステータスが OK となっただけでは利用可能とはならず、準備が整った任意のタイミングまでインスタンスが利用可能と判断されるのを待機できるので、ワーカーのようなインスタンスでも Blue/Green Deployment できます。

例えば、下記のようなスクリプトをユーザーデータに設定し、インスタンスの開始後に準備が整うまで利用可能になるのを待機させることができます。

#/bin/bash

set -eux

# インスタンスメタデータからインスタンスIDとリージョンを取得
instance_id=$(curl -s http://169.254.169.254/latest/meta-data/instance-id)
region=$(curl -s http://169.254.169.254/latest/meta-data/placement/availability-zone | sed 's/.$//')

# インスタンスのタグから AutoScaling Group を取得
group_name=$(aws ec2 describe-instances \
  --region "$region" \
  --instance-ids "$instance_id" \
  --query 'Reservations[].Instances[].Tags[?Key==`aws:autoscaling:groupName`][].Value' \
  --output text)

# ここでなにか処理して準備ができるまで待つ

# complete-lifecycle-action を呼び出してライフサイクルを続行する
# この処理が呼ばれるまでは新しいインスタンスは利用可能とならないので
# Terraform も待機される
aws autoscaling complete-lifecycle-action \
  --region "$region" \
  --auto-scaling-group-name "$group_name" \
  --lifecycle-hook-name "hello-asg-app-launching" \
  --lifecycle-action-result "CONTINUE" \
  --instance-id "$instance_id"

ただ、インスタンスが生きたままインスタンスの中のサービスが死んでも AutoScaling による故障が検出されないので、ヘルスチェックも独自に実装して適宜 aws autoscaling set-instance-health でインスタンスを Unhealthy にするか、software watchdog timer などでインスタンスを停止するなどの対策が必要になるかもしれません。

Application AutoScaling

今回試したものは EC2 インスタンスをスケーリングするための EC2 Auto Scaling ですが、その他の AWS リソースのスケーリングのための Application AutoScaling というものもあります。

Packer

カスタム AMI を作成するために Packer を使いました。手作業や AWS CLI で頑張るよりはだいぶ楽だと思います。それでも Docker イメージを作成するのと比べればそうとう手間ですが。

さいごに

頻繁に更新されるシステムで都度 AMI を作り直してデプロイするのは時間もかかるし検証も面倒そうなので、AMI を更新するのはミドルウェア構成が変わったときだけにして、アプリのソースは tar.gz で固めて S3 に置くなどしてインスタンスの開始時に S3 から展開してサービスを開始、アプリの更新時は AMI はそのままでアプリのソースだけ S3 経由でデプロイするのが運用しやすそうです(ミドルウェアレイヤーだけイミュータブルなイメージ)。

また、スケーリングポリシーはそれが必要となるようなシステムに成長するかどうかはやってみなければわからないだろうので最初は設定せず、最初は必要に応じて手動スケーリングで良いだろうと思います。EC2 インスタンスは AWS 側の都合でわりと突然死することがあるので AutoScaling でインスタンス数の維持(故障したら自動で再作成)だけでも十分有用です。

ただ、AutoScaling のために AMI をうまく作るために変に消耗するぐらいなら、ECS とかで Docker イメージをアプリのコード込でデプロイするのでも良いように思います。

Amazon Elastic Container Service for Kubernetes (EKS) を Terraform で素振り

少し前に EKS が Tokyo リージョンで使えるようになったので素振りしたメモ。残骸はこちら

なお、てっきり EKS も Fargate が使えるものだと思っていたのですが、そうでもなかったので(将来的にはできるようになる?)、EC2 でワーカーノード作ってます。

EKS の費用

EKS クラスタ を作成すると 1 時間あたり 0.20 USD、さらにクラスタのワーカーノードのために EC2/EBS の料金、あと Service で LoadBalancer を指定すると ELB の費用も必要です。

kubectl と aws-iam-authenticator

EKS を使用するためには Kubernetes の kubectl コマンドと、kubectl で IAM 認証を使うための aws-iam-authenticator をインストールする必要があります。

kubectl は Kubernetesの公式ドキュメント に則ってディストリビューションのパッケージマネージャーでインストールしてもよいし、AWSでバイナリがホストされている のでそれを直でインストールしても良いです。

aws-iam-authenticator も AWS にバイナリがホストされているのでそこからインストールします。

クラスタを作成

EKS クラスタの作成には結構な時間がかかります(8分ぐらいかかった)。

ECS クラスタは一瞬で作成されていましたが、ECS の場合はなにかインスタンス的なものが作られているわけではないのに対して、EKS クラスタの場合は Kubernetes のコントロールプレーン(Kubernetes Master)のインスタンスが実際にプロビジョニングされるためです(たぶん)。ELB や RDS と同類のものだと思えば良いですね。

なお、始めにお試しでマネジメントコンソールから作成してみたところ下記の記事のような問題で kubectl が失敗したので AWS CLI で作成するか Terraform などで作成するのが良さそうです。

タグ

EKS で使用する VPC・サブネット・EC2インスタンス には kubernetes.io/cluster/${cluster_name} のような名前のタグが必要です。このタグを目印に EKS がこれらを制御するようです。

shared はその VPC やサブネットが複数のクラスタから使用されることを許可します。値 owned はそのクラスタ専用になるようですが、どういう違いがあるのかはよくわかりませんでした。

なお、EKS のユーザーガイドにはそのような記述が無いようなのですが、ワーカーノードにアタッチするセキュリティグループにも同様にタグが必要です。

コントロールプレーンのセキュリティグループ(クラスタ作成時に指定するセキュリティグループ)ではなく、EC2インスタンスにアタッチするセキュリティグループです。Kubernetes が ELB を自動的に作成した際に ELB のセキュリティグループからワーカーノードのセキュリティグループへアクセス可能にするために、ワーカーノードのセキュリティグループが Kubernetes によって自動的に更新されるため、そのためにタグ付けが必要です。

タグがないと、ELB の作成時の更新(ELB のセキュリティグループからのアクセス許可を追加)は行われるものの、ELB の削除時の更新(追加されたアクセス許可を削除)が行われなくなります。

kubeconfig

EKS クラスタの作成後、kubectl で制御できるように ~/.kube/config を作成する必要があります。手作業で作成しなくても AWS CLI で下記のようにさっと作成できます。

aws eks update-kubeconfig --name hello-eks

Launch Configuration

EKS と直接関係はありませんが、ワーカーノードを Auto Scaling で作成できるようにするために Launch Configuration を作成する場合、使用している IAM User や IAM Role が下記のようにソースアドレスで制限されているとダメでした。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Deny",
      "Action": "*",
      "Resource": "*",
      "Condition": {
        "NotIpAddress": {
          "aws:SourceIp": [
            "192.0.2.123/32"
          ]
        }
      }
    }
  ]
}

Launch Configuration 以外にもいくつかソースアドレスで制限しているとダメな AWS リソースがあったと思います。

aws-auth の ConfigMap

ワーカーノードをクラスタと結合(普通はジョインと言うと思う)するためにはクラスタの作成後に Kubernetes に EC2 インスタンスプロファイルを認証するための ConfigMap を反映する必要があります。

aws-auth-cm.yaml をダウンロードしてインスタンスプロファイルの Role の ARN(Terraform の aws_iam_role.node.arn)を書き換えて反映します。

curl -O https://amazon-eks.s3-us-west-2.amazonaws.com/cloudformation/2018-08-30/aws-auth-cm.yaml
vim aws-auth-cm.yaml # rolearn  の部分を書き換え
kubectl apply -f aws-auth-cm.yaml

Kubernetes の Deployment や Service を作成

Service は ELB を使うので spec.typeLoadBalancer にします。

kubectl apply -f deploy.yaml

Service を作成すると ELB および ELB にアタッチされるセキュリティグループが自動的に作成され、さらに ELB からワーカーノードにアクセスできるようにするために、ワーカーノードのセキュリティグループに ELB のセキュリティグループからのアクセス許可が自動的に追加されます。

ELB の DNS 名は kubectl で確認できます。

kubectl get service httpd -o json | jq '.status.loadBalancer.ingress[].hostname' -r

サブネットの構成

クラスタの作成時に指定するサブネットで Public と Private を両方含めておくと、Kubernetes は自動的にサブネットの Public と Private を判断して Internet facing な ELB は Public に配置されます。

Private サブネットにはワーカーノードが配置されるわけですが、それは Kubernetes によって行われるわけではなく普通に EC2 インスタンスを実行したり AutoScaling を使うなりで配置するものなので、クラスタ作成時のサブネット指定は ELB が配置される Public サブネットだけで良いような気もしたのですが、ユーザーガイドを読んだ感じ Private サブネットも指定する必要があるようです。

クラスタ作成時に指定したサブネットには Kubernetes のコントロールプレーンと通信するための ENI が自動的に作成されるようなので、ワーカーノードが配置される Private サブネットも指定する必要がある、ということだと思います。

さいごに

EKS のユーザーガイドは下記にあります。

がしかし、これだけだと EKS の Kubernetes とは無関係な AWS の特有の事情しかわからないので、ちゃんと使うなら Kubernetes のドキュメントを読み進める必要があります。

クラスタを作成するだけでワーカーノードがなくても費用がかかるし、EC2 でワーカーノードを準備する必要があるので ECS と比べてお手軽感はありませんね。

また、ECS ならマネジメントコンソールで Task や Service の状態がいろいろ見れますが、EKS だとマネジメントコンソールではほとんどなにも見えなくて、基本的には kubectl を使う必要があります。ただ、どうせ CLI メインになるだろうので AWS CLI とか ECS CLI とかよりも kubectl で操作できる EKS の方が良いかもしれません。GUI が必要なら Kubernetes Dashboard というものも使えるようです。

EKS と直接は関係ない問題ですが、ワーカーノードを作成するために AutoScaling を使ってみましたが Terraform だと AutoScaling で AMI のアップデードに課題がありそうです。Rolling Update や Blue/Green Deployment は Terraform 単体できれいにやるのは難しそうです。