Quantcast
Browsing Latest Articles All 21 Live
Mark channel Not-Safe-For-Work? (0 votes)
Are you the publisher? or about this channel.
No ratings yet.
Articles:

Dockerコンテナが使用するIPレンジを変更する

環境はCentOS6.x以上もしくはCoreOS。Docker Docsに記載されている内容を実践して、少し知見を付け加えたものです。

前提:Dockerコンテナのネットワーク構成

Dockerサービスを起動すると自動的にdocker0と呼ばれるbridgeが作成され、すべてのコンテナはこのブリッジに接続される。

$ ip a show docker0
3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether 02:42:ba:8a:43:a5 brd ff:ff:ff:ff:ff:ff
    inet 172.17.42.1/16 scope global docker0
       valid_lft forever preferred_lft forever
    inet6 fe80::42:baff:fe8a:43a5/64 scope link
       valid_lft forever preferred_lft forever

docker0はホスト側と競合しないIPレンジを自動で引っ張ってきて、自身のIPもそこから割り当てる。コンテナが起動されると、コンテナのeth0はveth(Virtual Ethernet)としてdocker0に接続され、先のIPレンジから固有のIPアドレスが付与される。

$ brctl show
bridge name     bridge id               STP enabled     interfaces
docker0         8000.0242ba8a43a5       no              veth114c2f3
                                                        veth11751c2
                                                        veth34ea7d8
$ docker inspect `docker ps -ql`|grep "IPAddress"
        "IPAddress": "172.17.0.34",

docker0が使用するIPはランダムではないらしく、動作を見た限りでは基本的に172.17.42.1/16が割り当てられている。

コンテナ用IPレンジの変更

172.17.0.0/16docker0に割り当てられると、当然ながらホストOSのルーティングテーブルも書き換わる。/16とかなり広いレンジのクラスBアドレスが割り当てられることから、既存ネットワークとの関係でこれが不都合となることは往々にしてある。その場合にはIPレンジを任意のものとすることができる。

追記(2016-06-07)

以下手順ではDocker用のブリッジをすべて手動で作成してからDockerを起動しているが、Dockerは起動時にブリッジを自動生成するため、オプションだけでブリッジのIPレンジを変えることができる。/etc/default/dockerでの起動オプションに、--fixed-cidr=192.168.1.0/25オプションでコンテナ用IPレンジが指定できる。

参照:Customize the docker0 bridge

ブリッジの手動作成

まず、すでにDockerが起動している場合はサービスを停止し、docker0のブリッジを破棄する。

$ sudo systemctl stop docker
$ sudo ip link set dev docker0 down
$ sudo brctl delbr docker0

次に、新たな仮想ブリッジを手動で作成し、コンテナで使用したいIPレンジからIPアドレスを割り当てる。

$ sudo brctl addbr bridge0
$ sudo ip a add 192.168.1.1/24 dev bridge0
$ sudo ip link set dev bridge0 up

Docker起動オプションの設定

あとはオプション-b=bridge0を与えてDockerサービスを再起動すれば良い話だが、サービス起動時に永続的にオプションを付加させるため、/etc/default/dockerにこれを追記する。

$ echo 'DOCKER_OPTS="-b=bridge0"' >> /etc/default/docker

systemd

さらにCentOS7やCoreOSで、Dockerがsystemd管理下にある場合、unitファイルを編集して/etc/default/dockerEnvironmentFileに指定する必要がある。

docker.service
[Unit]
Description=Docker Application Container Engine
Documentation=http://docs.docker.com
After=docker.socket early-docker.target network.target
Requires=docker.socket early-docker.target

[Service]
EnvironmentFile=-/etc/default/docker
MountFlags=slave
LimitNOFILE=1048576
LimitNPROC=1048576
ExecStart=/usr/lib/coreos/dockerd daemon --host=fd:// $DOCKER_OPTS $DOCKER_OPT_BIP $DOCKER_OPT_MTU $DOCKER_OPT_IPMASQ

[Install]
WantedBy=multi-user.target

cloud-config.yml

CoreOSの場合はunitファイルの直接編集ができないため、cloud-config.ymlunitsで設定する。

cloud-config.yml
#cloud-config
coreos:
  units:
    - name: docker.service
      content: |
        [Unit]
        Description=Docker Application Container Engine
        Documentation=http://docs.docker.com
        After=docker.socket early-docker.target network.target
        Requires=docker.socket early-docker.target

        [Service]
        EnvironmentFile=-/etc/default/docker
        MountFlags=slave
        LimitNOFILE=1048576
        LimitNPROC=1048576
        ExecStart=/usr/lib/coreos/dockerd daemon --host=fd:// $DOCKER_OPTS $DOCKER_OPT_BIP $DOCKER_OPT_MTU $DOCKER_OPT_IPMASQ

        [Install]
        WantedBy=multi-user.target

サービス起動。

# CentOS6.x
$ sudo service docker start
# CentOS7.x
$ sudo systemctl daemon-reload
$ sudo systemctl start docker
# CoreOS
$ sudo coreos-cloudinit --from-file /usr/share/oem/cloud-config.yml
$ sudo systemctl start docker

TODO

ブリッジ作成を手でやってしまったので、起動時に自動生成できないか検討する。
先の追記で達成。

参考

Docker Network

/etc/default/docker

cloud-config.yml

CoreOSをインストールしてから接続するまで

前提

  • systemdを採用している。サービス管理はすべてsystemctlから。ログを見たいときはjournalctlを使用する。
  • パッケージマネージャーは存在しない。サービスはDockerで賄う。
  • システムファイルは原則として上書きできない。設定変更はcloud-config.ymlを通じて行う。
  • ローリングアップデートで自動的にOSアップデートがかかる。

インストール

いくつかやり方があり、CoreOSを立てる環境に応じて適切なものを選ぶ。

ネットワーキング

原則としてDHCPによりIPを自動取得して、SSHで繋がせることを前提としているが、固定IPで使用したい場合はcloud-config.ymlを使用する。

cloud-config.yml
coreos:
  units:
    - name: static.network
      content: |
        [Match]
        Name=ens*
        [Network]
        Address=192.168.1.1/24
        Gateway=192.168.1.250
        DNS=192.168.1.50

秘密鍵認証以外でのログイン

認証回避

あまりないとは思うが、ローカルから入る場合。まず認証を外して入る方法。

  1. GRUBのboot menuで矢印キーでdefaultでの起動を選択し、eを押下してboot optionを開く。
  2. load_coreos mout.usr=PARTUUID=$usr_uuid coreos.autologinになるよう修正してC-xでbootする。

パスワード認証

パスワード認証でsshしたい場合には、やはりcloud-config.ymlでの設定となる。

cloud-config.yml
users:
  - name: yourname
    passwd: hashed password
    ssh-authorized-keys:
      - "ssh-rsa AAAA..."

自動アップデートの停止

まだ運用開始して間もないので自分はいまいちわかってないのだが、どうも自動アップデートのときに(当然ではあるが)勝手に再起動もかかるようなので、嫌であれば自動アップデートは停めた方が無難。

cloud-config.yml
coreos:
  update:
    reboot-strategy: "off"

cloud-config.yml

書き方に関しては公式を見ておけば困らない。

coreos-cloudinit/cloud-config.md at master · coreos/coreos-cloudinit

起動時に自動で読み込まれて反映されるが、手動で再読込したいときは下記コマンド。

$ sudo coreos-cloudinit --from-file /usr/share/oem/cloud-config.yml

aws-cliでLambdaのScheduled Eventを作成する

AWS CLI自体が今月始めたばかりぐらいなのだが、とりあえず手始めにということで、Lambda関数を作成してみた。昨年リリースされたLambdaのスケジュール実行を利用し、EC2インスタンスの自動起動/停止を題材としている。

IAMロールの作成

AWSにおけるリソースアクセスの権限制御にはIAMが使われるわけだが、リソース間のアクセスには「ユーザー」や「グループ」ではなく「ロール」が使われる。順序としては、Lambdaが引き受けることのできるロールを作成し、そのロールに対して、対象EC2の停止/起動が可能なポリシーを割り当てる、という2段階になる。

まずロール作成のため、設定を書いたjsonファイルを作成する。

lambda_role.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Sid": ""
    }
  ]
}

書式に関してはServiceのところで今回はLambdaに割り当てるロールであることを宣言している。sts:AssumeRoleについては結局のところ自分は理解しきれなかったのだが、以下の記事に詳細な解説がある。要はこのロールを何に対して割り当てることができるのかを定義する部分で、AWSリソースであるか否かも問わないらしい。

IAMロール徹底理解 〜 AssumeRoleの正体 | Developers.IO

上記jsonファイルをインプットとしてcreate-roleする。地味にハマったのだが、--asume-role-policy-documentにおけるfile://接頭詞は必要なので注意。

$ aws iam create-role --role-name "lambdaEc2Execution" --assume-role-policy-document file://lambda_role.json

続いてポリシーを割り当てる。まず、ポリシーをjsonで記述。

start_stop_ec2_policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Stmt0000000000001",
      "Effect": "Allow",
      "Action": [
        "ec2:StartInstances",
        "ec2:StopInstances"
      ],
      "Resource": [
        "arn:aws:ec2:ap-northeast-1:xxxxxxxxxxxx:instance/xxxxxxxxxx" # 対象インスタンスのARN
      ]
    }
  ]
}

jsonをインプットにポリシーを作成。

$ aws iam create-policy --policy-name "StartStopEc2" --policy-document file://start_stop_ec2_policy.json

作成したポリシーを先のロールへ割り当て。

$ aws iam attach-role-policy --role-name "lambdaEc2Execution" --policy-arn "arn:aws:iam::aws:policy/StartStopEc2"

Lambda関数の作成

関数内のコードには以下記事のものを使わせていただいた。

LambdaのScheduleイベントでEC2を自動起動&自動停止してみた#reinvent | Developers.IO

コードファイルは予めzip圧縮しておく。ちなみに圧縮前のファイルの拡張子が正しくないと、アップロードしても適切に処理されなかったので一応注意。

$ zip startEc2.zip startEc2.js

Lambda関数を作成。handlerがいまいち飲み込めていないのだが、呼び出す関数の名前らしい。jsファイル名をそのまま入れたら通った。

$ aws lambda create-function \
--function-name "startEc2" \
--zip-file fileb://startEc2.zip \
--runtime "nodejs" \
--role "arn:aws:iam::XXXXXXXXXXXX:role/lambdaEc2Execution" \
--handler "startEc2.handler"

invokeサブコマンドで試験実行。

$ aws lambda invoke --function-name "startEc2" --log-type Tail outfile.txt
$ cat outfile.txt
"Started Instance"

Schedule作成

Lambda関数は何をトリガーとして実行するか(例えばS3にファイルを保存したこととか)をevent sourceとして定める。Scheduled実行させるには、CloudWatch Eventsをevent sourceとする。aws lambdaコマンドにはそれっぽいcreate-event-sourceサブコマンドが容易されているが、これはKinesisとDynamoDBにしか対応していないので、実際にはCloudWatch Eventsの側から設定していく形を取る。

まず、Lambda functionに対してCloudWatch Eventsから実行できるようパーミッションを与える。source-arnはあらかじめ作成するCloudWatch Eventsの名前から逆算しておく。

$ aws lambda add-permission \
--function-name "StartEc2" \
--statement-id "" \
--action 'lambda:InvokeFunction' \
--principal events.amazonaws.com \
--source-arn arn:aws:events:ap-northeast-1:xxxxxxxxxxxx:rule/StartAtMorning

CloudWatch Eventsでルールを作成。CloudWatch Eventsにおけるcron書式は以下の点でいわゆるLinuxのcrontabとは差異があるので注意。

  • 5桁ではなく6桁から成り、6桁目で「年」を指定できる。
  • 曜日を問わない場合は?として指定しなければエラーになる。
  • 時刻はUTCで解釈される。

以下では毎日UTCで23時、すなわちJSTで8時を指定している。

$ aws events put-rule --name "StartAtMorning" --schedule-expression "cron(0 23 * * ? *)" --state ENABLED

作成したルールのtargets、つまり実行対象にLambda functionを指定する。

$ aws events put-targets --rule "StartAtMorning" --targets Arn=arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:StartEc2,Id=xxxxx

以上、これで毎朝7時にインスタンスが自動起動するはず。停止については省略するが、コードや名前、スケジュール日時を変えてやればOK。

結果として想像以上に手数が必要で、ちょっとだけ大変だった。要するにGUIだとなんとなくポチポチやれば出来ていたものが、きちんと一つ一つの操作を理解していないと適切にコマンドのオプションを使えなくてうまく行かない、ということなのだと思う。そういう意味だと、CLIを使うことで一つ一つのリソースやオプションの意味に向き合い直すことができるので、思わぬ副作用があった。

参考

Scenario 6: Run an AWS Lambda Function on a Schedule Using the AWS CLI - Amazon CloudWatch
Amazon Web Services ブログ: 【AWS発表】CloudWatch Events – AWSリソースの変更を監視
AWS Lambda Permissions Model - AWS Lambda
[JAWS-UG CLI] Lambda:#1. Lambda関数の作成と実行 - Qiita
aws-cli - [JAWS-UG CLI] IAM:#24 IAMロールの作成 (ECSサービス) - Qiita

AWS1年目無料期間でやったこととハマったこと

昨年のJAWS DAYS(3/22)のハンズオンでAWSアカウントを作り、そこから1年間の無料期間でいろいろやったことを書き出す。正直言ってきちんと活用し切れなかったなぁという思いがあるので参考にならないかも。

最初にやること

気を付けるポイントとしては セキュリティ課金 かと。以下、だいたいがよく言われていることではある。

IAMユーザーを作成する

IAMはAWSの権限管理のためのサービス。最初にAWSアカウントを作成したときに使った、メールアドレスをユーザー名とするユーザーはいわゆるrootユーザーにあたるので、AWSコンソールへのログインにはIAMで新たにユーザーを作成して使用するのが適切。またGoogle Authenticator等で他要素認証も設定しておく。

Trusted Adviserを設定する

Trusted AdviserはAWSの使用状況がコスト、セキュリティ、パフォーマンス等の面で適切な状態にあるかチェックしてくれる。例えば0.0.0.0/0に向けてオープンなポートのあるセキュリティグループを作ったりすると警告してくれる。サポートプランをグレードアップしない限り、すべてのチェック項目を有効化することはできないが、ベーシックサポートのままで使える項目だけでかまわないので有効化が推奨。

CloudTrailを設定する

CloudTrailはAWS APIへのアクセスをロギングしてくれるサービス。不適切なアクセスがないか監視する、という意味でも、自分の操作を記録しておく、という意味でも、有効化がベター。ただし、S3に保存されたログが課金される。

CloudWatchで利用料金を監視する

AWSの監視サービスであるCloudWatchを使って、利用料金の監視が可能なのでセットする。

Amazon CloudWatchでAWSの利用料金を監視する - Masteries

AWS1年目だと基本的には「0円運用」を意図していると思うので、0円を閾値にアラートが上がるようにするのが普通。推奨としては、これに加えて500円か1000円ぐらいのラインでも監視をした方がいい。アラートは閾値を突破したとき1回発報されるだけなので、例えば0円を一度超えてアラートが上がり、それに対応した後、また課金が発生した場合、「0円」閾値だけだとそれには気付けなくなってしまう。

VPCとセキュリティグループについて正しく理解する

VPSを借りる場合は、いわゆる基本的なLinuxの「要塞化」によってセキュリティを担保したと思うが、AWS自体のアーキテクチャーでセキュアにすべきところなので、AWSのセキュリティ構成は早めに理解した方がいい。ポイントはVPCとセキュリティグループ。VPCはAWS内に閉じられた、プライベートなネットワークセグメントを切るための設定であり、セキュリティグループはファイアウォールにあたる。オンプレのネットワーク機器の設定と同様、これを理解しておかないと、まともにAWSリソースへのアクセスすらできなかったりもするので。。。

AWS Black Belt Techシリーズ Amazon VPC

ハマったこと

特に課金方面を中心に、AWS利用の上でハマったポイントをいくつか上げておく。

EC2が最初の関門

「No EC2」だとかサーバーレスアーキテクチャーだとか言われる時代にはなったけど、やっぱり最初に触るのはEC2で、それ故ハマりやすいのもEC2だった。

  • EIPを設定しない限りは終了→起動のたびにDNS名とIPが変わる。
  • セキュリティグループに穴を開けないと、firewalldでいくら穴開けても通信できない。
  • VPCがインターネットに出られるようになっていないと、EC2にパブリックIPが振られない(=外からアクセスできない)。
  • 後述の通り課金上ハマった罠がいくつかあった。
  • EC2 Amazon Linuxデフォルトの状態では開発系のツールがほとんど入っていない。Development Toolsをまとめて入れると楽。

課金が発生しても特にお知らせはない

普通の感覚だと、課金が発生したら月末に請求書でも来そうなものなのだが、AWSの場合はデフォルト設定だと特に通知はなく、黙って登録クレジットカードから決済だけされている。CloudWatchでアラートを掛けていたとしても、さすがに精神に悪いので、請求書の取得は設定しておくのがオススメ。

メールで送付された請求書の取得 - AWS 請求情報とコスト管理

無料使用可能な条件は正しく確認する

何が無料使用可能なのかは当たり前ではあるが正しく理解しておく。

例えば自分の場合EC2で課金が発生してしまったのだけど、あれは(本記事執筆時点では)t2.microに限り750時間/月の稼動まで無償になる。つまりはインスタンス1台をずっと起動しっぱなし(24時間×30日=720時間)ぐらいを想定しているわけだが、自分はこの時間制限を見落としていて、EC2インスタンスを2台立ち上げっぱなしにしてしまって課金になった。

あるいはEC2に固定IPを付与するElastic IPは、 起動中のインスタンス に紐付けられている間に限り無償である。インスタンスを落としたり、割り当てをせずにいたりすると少額だが課金対象となる。

リージョンへの注意

AWSコンソールだと右上に表示されているリージョン(DCの場所)、当初はあまり意識していなくてオレゴンにインスタンス立ててレイテンシーに頭を悩ませるみたいなこともあった。リージョンの概念を理解して意識する。

  • リージョンによってサービスの価格は異なる(東京はわりと高め)。
  • 特定リージョンじゃなければリリースされていないサービスがある。そのようなサービスに飛んだときは、自動でコンソールの表示リージョンが変更される。
  • コンソール内で表示されるのは基本的に1リージョンごと。 例えば東京リージョンのEC2を表示しているとき、オレゴンで立っているインスタンスは見えない。

情報がすぐ陳腐化する

新しいサービスがどんどん追加されているのも当然ながら、細かいアップデートもちょこちょこと入るので、ネットで見つけたAWS系のtipsが実はすでに使えないですなんてことも少なくなかった。AWSだけに言える話じゃないけど、まずはブログやQiitaより公式のdocsを確認する習慣をつけるようになった。

1年間でやったこと

JAWS-UGに参加する

日程の都合もあったりしてそれほど参加はできなかったけど、Japan AWS User Group、通称JAWS-UGのイベントに何度か参加させていただいた。

JAWS-UG – AWS User Group – Japanは、Amazon Web Servicesの利用促進や情報交換のためのユーザーグループです。 | JAWS-UG

地域別、あるいは分野別(コンテナ、IoT、CLIなど)に複数の支部があり、それぞれがそれなりの頻度で勉強会や懇親会を開催しているので、探せば来週にでも行かれたりするはず。昨年立ち上がったところだと「初心者支部」なんてのもあるから、AWS1年目だと良いかも。私は立ち上げの第1回だけ参加してます(その後は日程合わず。。)今年はCLI支部とOpsJAWSに頻繁に行ってみたいなと思っている。

参考書を読む

AWSは単なる「クラウドサービス」と言うより、数多のXaaSサービスが連関して構成された「クラウドアーキテクチャー」であって、ただ1つ1つのサービスを使っていてもなかなか全体像が見えにくい。なので実際の構成パターンを交えて紹介された書籍を読み、活用イメージを付けた方が良いと思う。

注意すべきはAWSのサービスリリースがめちゃくちゃ速いので、1年前の書籍であっても記述が古くなっている場合があるという点。必ず最新の状況とは照らし合わせるべき。

AWSの公開資料を読む(2016-06-08追記)

AWSがかなりの数の技術文書(主にベストプラクティス)を公開している。

Webinarを受ける

AWSが開催しているオンラインセミナー(Webinar)が存在する。

国内のクラウドセミナー・イベントのスケジュール | AWS

だいたい(毎週?)火曜のお昼と水曜の夜の開催で、 初心者向けと、中級者向けであるBlack Beltの2種類 (⇒2016年4月からBlack Beltに1本化されたとのこと:AWS Solutions Architect ブログ: 4月(前半)のAWS Black Belt Online Seminarのご案内 がある。自分のレベルと興味関心に合わせておこのみで。

また「Black Belt」で検索すると過去資料もSlideShareなどで見られるのでオススメ。

セルフペースラボを活用する

正確には自分は使っていないのだけど、AWSが公式に公開しているセルフペースラボという学習環境があるので、実際にAWSアカウント取得しても作るものがないしハンズオンできない!という場合には活用すると良さそう。

とにかく何か作る

どうせ無料なんだから、何に使うでなくてもとにかくリソースを作る。触ってみる。何か作るものがあるならAWSで出来ないか?と考えてみる。とはいえ自分もなかなかハンズオンできなかったのだが、やったのはこんなところ。

  • EC2を立ててクラウド開発環境にする(今の使用中)
  • EC2でウェブサーバーやDBサーバーを立ててみる。
  • S3(Glacier)にMacの写真をバックアップする。
  • SESを使おうとしたがメールの審査で弾かれてよくわからなくて断念。
  • hubotからAPI Gateway経由でLambdaを叩き、EC2を起動する。
  • CloudWatchのアラートをSNS、SQSを通じてslackに流す。
  • RDSを立ててEC2からmysqlコマンドで叩いてみる。
  • Config Rulesでルールに沿わないEC2の起動を禁止する。 ※無料じゃないです。
  • AWS CLIですべての操作ができないか試してみる。
  • Elasticsearch ServiceにEC2からデータを流す(今やってる。近日中にQiitaに上げる) ⇒2016-04-12 上げました ZaimのデータをLogstashでAmazon Elasticsearch Serviceへ投入する - Qiita

Redshiftを個人で使うといっても使いようがなかったり、無料枠をすべて使い切れるわけでもないと思うが、とにかく片っ端からいじってみたら良いと思う。AWSは既存インフラの単なる代替ではなく、まったく異なるアーキテクチャーと操作性を持っているので、とにかく触って感覚を掴むことが一番大事だなと、この1年を通じて思った。

現状と今後について

まだまだ知識が足りたとは言い難いので、継続して個人環境も用いた学習は進めていくが、自分が現在Opsに身を置いているということも鑑みて、注力すべき方向は見えてきたかなと。

  • AWSの最適な運用を考える。Config Rules等を用いてシステマチックに縛る。
  • Infrastructure as Codeともうちょっと上手いこと絡めたい。Terraformとか試したい。
  • 個人的にLambdaを極力活用する。hubot経由でいつでもいろんなものを叩けるような環境がほしい。
  • JAWS-UGにもっと絡む。

2年目も頑張る :raised_hands:

ZaimのデータをLogstashでAmazon Elasticsearch Serviceへ投入する

資産状況を簡単にグラフ化出来ないかと考えて挑戦。家計簿サービスの中でもZaimはAPIを公開しており、jsonでデータの取得ができる。これをPythonを使って取得し、勉強も兼ねてAmazon Elasticsearch Serviceへ投入する形でグラフ化を実現してみた。なお、大量のjsonの投入にはせっかくなのでElastic製のlogstashに挑戦している。

Amazon Elasticsearch Service

AWSの提供するElasticsearchのマネージドサービス。バージョンが最新より若干古いもので提供されているのがネックではあるが、最も簡単かつ高速にElasticsearchを使える手段の一つかと。Kibanaもデフォルトで提供される。

特に設定上難しい箇所はないので、Webコンソールからさくさくと作成する。アクセス制御のポリシーは今回IPによる許可を行なった。これはElasticsearchだけではなく、Kibanaのアクセス制御でもあるので、例えばデータ投入はクラウド上のサーバーから行い、Kibanaの確認はローカルPCで行うような場合には、それぞれIPを指定する必要がある。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "",
      "Effect": "Allow",
      "Principal": {
        "AWS": "*"
      },
      "Action": "es:*",
      "Resource": "arn:aws:es:ap-northeast-1:xxxxx:domain/xxxxx/*",
      "Condition": {
        "IpAddress": {
          "aws:SourceIp": [
            "xx.xx.xx.xx"
          ]
        }
      }
    }
  ]
}

Zaim API via Python

Zaim APIを叩くのにはPythonのrequestsを使用した。書き方はrequestsのドキュメントを参考にしている。

get_zaim.py
# coding: utf-8
import requests
from requests_oauthlib import OAuth1Session
from requests_oauthlib import OAuth1

consumer_key = u"XXXXX"
consumer_secret = u"XXXXX"

request_token_url = u"https://api.zaim.net/v2/auth/request"
authorize_url = u"https://www.zaim.net/users/auth"
access_token_url = u"https://api.zaim.net/v2/auth/access"
callback_uri = u"http://chroju.net/"
get_money_url = u"https://api.zaim.net/v2/home/money"

def oauth_requests():
    auth = OAuth1Session(consumer_key, client_secret=consumer_secret, callback_uri=callback_uri)
    r = auth.fetch_request_token(request_token_url)
    resource_owner_key = r.get('oauth_token')
    resource_owner_secret = r.get('oauth_token_secret')

    authorization_url = auth.authorization_url(authorize_url)
    print 'Please go here and authorize,', authorization_url
    redirect_response = raw_input('Paste the full redirect URL here:')
    oauth_response = auth.parse_authorization_response(redirect_response)
    verifier = oauth_response.get('oauth_verifier')

    auth = OAuth1Session(client_key=consumer_key, client_secret=consumer_secret, resource_owner_key=resource_owner_key, resource_owner_secret=resource_owner_secret, verifier=verifier)
    oauth_token = auth.fetch_access_token(access_token_url)

    resource_owner_key = oauth_token.get('oauth_token')
    resource_owner_secret = oauth_token.get('oauth_token_secret')

    get_json(resource_owner_key, resource_owner_secret)

def get_json(resource_owner_key, resource_owner_secret):
    headeroauth = OAuth1(consumer_key, consumer_secret, resource_owner_key, resource_owner_secret, signature_type='auth_header')
    r = requests.get(get_money_url, auth=headeroauth)
    print r.content

if __name__ == "__main__":
  oauth_requests()

これを実行するとjsonで愚直に家計簿データが出力されるので、jqを介して整形した上でファイルへ保存しておく。

$ python27 get_zaim.py | jq > zaim.json

Elasticsearch

mapping

一度Amazon ESを立ち上げれば、その後の利用方法は通常のElasticsearch同様。まずzaim APIで取得できるjsonファイルの書式に合わせ、mappingをあらかじめ行う。

$ curl -XPUT "http://xxx.ap-northeast-1.es.amazonaws.com/lifelog" -d '
{"mappings" : {
"zaim" : {
"properties" : {
"id" : { "type" : "integer"},
"user_id" : { "type" : "integer"},
"date" : { "type" : "date", "format" : "yyyy-MM-dd"},
"mode" : { "type" : "string" },
"category_id" : { "type" : "integer" },
"genre_id" : { "type" : "integer" },
"from_account_id" : {"type" : "integer"},
"to_account_id" : {"type" : "integer"},
"amount" : {"type" : "integer"},
"comment" : {"type" : "string"},
"active" : {"type" : "integer"},
"created" : {"type" : "date", "format" : "yyyy-MM-dd HH:mm:ss"},
"currency_code" : {"type" : "string"},
"name" : {"type" : "string"},
"receipt_id" : {"type" : "integer"},
"place_uid" : {"type" : "integer"},
"place" : {"type" : "string"},
"path" : {"type":"string"}
}
}
}
}'

mapping設定が問題ないことを確認。

$ curl -XGET "http://xxx.ap-northeast-1.es.amazonaws.com/lifelog/_mapping" | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   733  100   733    0     0  58050      0 --:--:-- --:--:-- --:--:-- 61083
{
  "lifelog": {
    "mappings": {
      "zaim": {
        "properties": {
          "@timestamp": {
            "type": "date",
            "format": "dateOptionalTime"
...

logstash

Elasticsearchに複数のjsonを同時に投入するには、おそらくbulk APIを利用するのが最も一般的である。しかしbulk APIで投入するjsonは、データ部分とindexを交互に記述したものとしなくてはならないため、今回のようにAPIで一括取得したjsonを流すには向いていない。

代替手段としてはfluentdなどを利用する手法もあるが、Elastic社が提供しているということで logstash を活用してみた。logstashはfluentdと似たストリームデータの解析、処理を行うためのツール。特にElasticsearch専用というわけではなく、例えばDatadogやinfluxDBへデータを流していくこともできる。

今回はjsonをすでにファイルへ出力済みなので、ファイルをcatしてstdinのinput pluginで投入し、Elasticsearchをoutput pluginとして設定した。inputとoutputにどんなpluginが用意されているかは、ドキュメントを眺めてみると感覚がつかめると思う。

インストール

Officialの記載に従ってyumでインストール。

$ sudo yum install logstash

confファイルの作成

logstashは処理したい内容を記載したconfファイルを利用してデータ処理を行わせる。ファイルには大きく分けてデータの受信元を書いた「input」、解析方法を書いた「filter」、出力先を書いた「output」の3つのplugin設定を記載する。今回利用したconfファイルは以下の通り。

pipeline.conf
input {
  stdin {
    codec => json
  }
}
filter {
  json {
    source => "message"
  }
}
output {
  elasticsearch {
    hosts => ["http://xxx.ap-northeast-1.es.amazonaws.com/"]
    index => "lifelog"
    document_type => "zaim"
    document_id => "%{id}"
  }
  stdout {
    codec => rubydebug
  }
}

「input」は標準入力(stdin plugin)とし、jsonとして解釈させた。

ファイルが入力元になるので、file pluginで指定することも可能であるが、この際注意すべきは、当たり前かもしれないがlogstash起動以降の入力が処理の対象になる、すなわちfile pluginをinputとして指定した場合は、追記部分が処理対象だということ。ファイル内に元々書かれた内容を頭から処理させるには、start_position => beginningの指定が必要になる。また一度読み込んだファイルは読み込み位置が~/.sincedb_...に記録されるので、このファイルを削除しない限りstart_position => beginningは機能せず、途中からの読み込みになる。

「filter」ではjsonのどの部分を読み込むか設定している。codec => jsonとしてinputされたデータは生でjson処理されるわけではなく、メタ属性などが追加されるので、データ部分だけを純粋に取り出すときはmessage field以下を明示的に指定する必要がある。

「output」ではelasticsearch pluginとstdout pluginを指定。前者ではdocument IDの指定ができるので、Zaimのjsonデータに元々含まれているid fieldを使うよう指定している。stdout pluginはデバッグ目的で指定した。codecrubydebugとするのがどうも通例の模様。

実行

設定ファイルを作成したら、logstashを実際に実行する。

$ cat zaim.json | logstash -f pipeline.conf

Elasticsearchにリクエストをかけて、登録できたことを確認する。

$ curl -XGET "http://xxx.ap-northeast-1.es.amazonaws.com/lifelog/zaim/_count" | jq
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    62  100    62    0     0    384      0 --:--:-- --:--:-- --:--:--   385
{
  "count": 1976,
  "_shards": {
    "total": 5,
    "successful": 5,
    "failed": 0
  }
}

Kibana

Elasticsearchへの登録が問題なく完了していれば、すでにKibanaからも閲覧が可能になっている。

zaim.png

gollumをAlpine LinuxでDocker化

最近勉強したことをメモするのにgollumを使っていたのだけど、いまどきCentOS7に何も考えずこれを動かしておくのも勿体無いなと思い、Alpine LinuxでDockerコンテナ化してCoreOSで動かすことにした。Alpine Linuxは当方初挑戦である。今回の成果はGitHubにも上げました。

chroju/docker_gollum: gollum on docker (Alpine Linux)

gollum ?

Github Wikiだけを切り出して汎用的に使えるようにしたもの、といった位置づけらしい。特徴としてはMarkdownやReSTといった複数の書式が使えるという点、出力がGithub Wikiを元としているのでかなり見慣れたスッキリした見た目になる点など。Gemで提供しているので取り回しがしやすい点も重宝する。

インフラ環境の選択

ホスト

Dockerコンテナを置く環境としては、さくらクラウドのCoreOSを使うことにした。理由は単純にクーポンを持ってるからなのだけど、1台自由に使えるDockerホストは欲しいと思ってた。検証環境としては大変便利だと思うので。

クラウドなので何も考えずに使えるのは使えるんだけど、一点だけ注意点としてはCoreOSのバージョンが最新とは限らないようなので、起動後に一度以下コマンドを実行して手動でバージョンを上げておく必要がある。自分の場合はdocker pullが正しく動かなかった。

$ sudo update_engine_client -update

コンテナ

時代の潮流に乗ってAlpine Linux。 正直言って必要な部分だけしか学べてないので、これ以降書いていることと異なるベストプラクティスがあるかもしれない。 Docker用軽量OSということで簡単に使おうと思ってたけど、OSはOSなので時間作ってもうちょい掘り下げたいところ。

※2016-05-02 CMDを修正。crondが後ろだと動かないことに気付いた。

Dockerfile

取りあえず完成版を貼る。

FROM ruby:alpine
MAINTAINER chroju

WORKDIR /root

ADD files/id_rsa /root/.ssh/id_rsa
RUN apk --update --no-cache add git g++ ruby-dev linux-headers icu-dev libxml2-dev libxslt-dev build-base openssh && \
ssh-keyscan -t rsa github.com > ~/.ssh/known_hosts && \
gem install gollum github-markdown redcarpet org-ruby --no-ri --no-doc -- --use-system-libraries && \
git config --global user.email "chor.chroju@gmail.com" && git config --global user.name "chroju" && \
git clone git@github.com:chroju/gollum_articles /root/gollum && \
touch /etc/periodic/hourly/gitpush && \
echo -e "#!/bin/sh\ncd /root/gollum && git push origin master" >> /etc/periodic/hourly/gitpush && \
chmod a+x /etc/periodic/hourly/gitpush

ADD files/auth.rb /root/gollum/auth.rb

CMD crond && /usr/local/bundle/bin/gollum /root/gollum --config /root/gollum/auth.rb

EXPOSE 4567

Dockerfile、現状RUN&&で複数コマンド書き連ねるみたいのがベストプラクティスになってて、仕様がなんだかイケてない気がする……ってのはここでは置いておいて。やっていることはそれほど多くないとは思う。Alpine LinuxのイメージはRubyが入ったものがあったので、Gemであるgollum動かす上で選定した。

apk add

Alpine Linuxのパッケージ管理はapkを使う。--updateでパッケージリストをアップデートし、--no-cacheでキャッシュを捨ててイメージを小さく保つ。入れているのはgollumで必要なものとgitコマンドを使うためのopensshだが、不要なものもちょっと入ってるかも。

ssh-keyscan

gollumで書いた記事のバックアップとして、コンテナからGitHubへやり取りさせようとしていたのだが、これが何故かHost key verification failed.になってしまった。ググったらちょうどいいstack overflowが出たのでそのまま解消策として採用してるけど、具体的になぜこれが対策になるかはまだ調べてない。

docker - Dockerfile Cannot access GIT repo with my private key - Stack Overflow

GitHubでの記事保存

gollumで書いた記事はそのままだとコンテナの停止と共に消えてしまうので、先述の通りGitHub上に保存している。本来であればCoreOS上でレポジトリを持っておき、Dockerにマウントさせる形を取りたかったのだが、どうもそれだとgollumが正常に動作しないので諦めた。現状はgollumの記事を保存しているGitHubレポジトリをRUNの段階でgit cloneし、コンテナの起動後はcronで定期的にgit pushさせるようにしている。

Alpine Linuxにおけるcron

Alpine Linuxのcronについては、OfficialnのWikiを探したら次の場所に記載があった。

Alpine Linux:FAQ - Alpine Linux

/etc/periodic配下に15minhourlydailyといったフォルダがあるので、必要とする間隔のフォルダ内に実行可能ファイルを置けばよい。なお、ファイルは拡張子を省く必要があるので、.shは不要。

また上記FAQ内にはcrondが自動起動されるかのような記述があるが、自分の環境では起動しなかった(Dockerコンテナ上のAlpineだとダメなのかも?)ので、明示的にCMDで起動する必要がある。

gollumの認証

gollumはデフォルトだと認証機構を持たないので、簡単な認証を設ける。

auth.rb
module Precious
  class App < Sinatra::Base
    use Rack::Auth::Basic, "Restricted Area" do |username, password|
      [username, password] == ["chroju", "xxxx"]
    end
  end
end

このファイルをgollumのディレクトリに置き、gollum実行時に--configオプションで渡すと認証が有効になる。この構成ではgollumディレクトリはgit pushすることにしているので、.gitignoreでauth.rbを無視させることを忘れずに。

docker run

必要なコマンドはすべてDockerfileのCMDに入れ込んだので、実行時はシンプルにポートフォワーディングだけ設定すればOK。

$ docker run -d -p 4567:4567 gollum:latest

CloudTrailと連携させたElasticsearch Serviceをカスタマイズして使う

Amazon CloudTrailのログをElasticsearchに投入してKibanaで見やすくするソリューションはわりと知られているが、特に最近はCloudWatch Logsに「Streaming to Amazon Elasticsearch Service」機能があるので、ワンクリックでKibanaの可視化が出来たりする。具体的に言うと、Elasticsearchの新規インスタンス作成、CloudWatch LogsからElasticsearchへログを送るためのLambda、必要なIAM Roleの設定がさっくりできる。

参照:【運用】CloudTrailで取得した監査ログをElasticSearch Serviceで活用する【簡単設定】 | Developers.IO

ただこの機能で作ったElasticsearchの設定だと、自動作成である故に少々運用に辛いポイントがいくつかあったので、カスタマイズした点をまとめてみる。

not_analyzed設定

自動作成されたElasticsearchのmappingには、not_analyzedが一切設定されていない。なのでリージョン名すら「ap」「northeast」「1」が分かれてグラフ化されるという悲しいことになるので改善する。

CloudTrailのログだけあってstringのフィールドはかなり多いので、まとめてdynamic_templatesを当ててしまうのが早かった。インデックスは日付毎に切られるので"cwl-*"でワイルドカード指定にする。

mapping.json
{
    "template": "cwl-*",
    "mappings": {
        "_default_": {
            "dynamic_templates": [
                { "es": {
                      "match": "*",
                      "match_mapping_type": "string",
                      "mapping": {
                          "type": "string",
                          "index": "not_analyzed"
                      }
                }}
            ]
        }
    }
}

curlで設定。

$ curl -XPUT xxx.es.amazonaws.com/_template/mapping -d @mapping.json

type名の変更

typeにはCloudWatch Logsのロググループ名が割り当てられるのだが、ロググループ名というとスラッシュを含める場合が少なくない(はず?)。Elasticsearchのtypeにスラッシュが含まれるというのがどうにも気持ち悪かったので、任意のものに変更する。

変更箇所としては、「Streaming to Elasticsearch Service」機能で作られたLambda Function内。function transformがElasticsearchへのRequest Bodyを構成しているので、ここの以下の箇所を変更する。

        var action = { "index": {} };
        action.index._index = indexName;
        action.index._type = "logs";
        action.index._id = logEvent.id;

またindexも同様にここで変えられる。デフォルトではログの日付毎に別のindexが切られ、cwl-YYYY.MM.DDの形式で名前が充てられるが、例えば1つのindexにまとめたいのであれば、indexNameに固定値を指定することで実現できる。日付でindexを分けておくと、古いログをローテートするような実装を行いたい場合、indexごとごっそり消せばよいので楽だったりするが、そのあたりは好みの問題かなと思う。

個人的な見解としては、CloudTrailのログは別で保存されており、Elasticsearchで永続的にログを保持する必要はなかった(直近1か月ぐらい取りあえずKibanaで見られればよい)ので、indexはそのままで外からcronで削除のリクエストを定期的に発行している。ドキュメントの有効期限を設定するttlの機能もあるようだが、Deprecatedとのことなので採用はしなかった。

参考:TTL Documents, Shield and Found | Elastic

Cluster Healthの適正化

デフォルトで作成されたElasticsearch Serviceのインスタンスは、ダッシュボードで見たときCluster HealthStatusが"Yellow"になってしまう。これはデフォルトのノード数が1であるため、同じくデフォルト設定の条件であるレプリカ数=1を満たせないことによる。解消するにはノードを増やすかレプリカ数を0にするかだが、CloudTrailのログであれば耐障害性もそこまで必要ないので、後者を選択する。

replica.json
{
    "template" : "*",
    "settings" : {
        "number_of_replicas" : 0
    }
}

先述の通りindexが毎日増えていく設定となっているため、Index Templatesでデフォルトの設定としてしまう。

$ curl -XPUT xxx.es.amazonaws.com/_template/replica -d @replica.json

Docker複数コンテナから成るサービスの取り扱い

更新:2016/09/23 23:30

当初「EFKスタックで始めるDocker Compose on CoreOS」というタイトルにしていましたが、EFKに全然触れていないことに気付いたのでタイトル変更。

Dockerをどのようにサービスとして扱っていくか、という全体的なライフサイクル、運用的な部分を知りたくて、EFKスタックをDocker化して動かしていくにはどうするかということを学んでみました。要するにやりたいのは、 複数コンテナから成り立つサービスを、いかに永続化して動かすか。

前提

  • ホストOSはCoreOSとする(元々さくらクラウドで借りていたので)。従ってローリングアップデートによるOS再起動への対応が必要。
  • ただしCoreOS1台のみの構成のため、フェイルオーバーは考慮しない。
  • 勉強のために各Dockerイメージは公式の配布イメージ等を使わず、自分でDockerfileを書く。
  • DockerイメージのベースOSはAlpine linuxを使う。

複数コンテナの協働

いわゆるウェブサーバーとDBサーバーを別でコンテナ立てて一緒に動かすような需要。今回の場合はElasticsearch、Kibana、fluentdを別コンテナで立てることにした。前知識だと、この分野はDocker Composeを使えばいいのかな?と思っていたのだけど、CoreOSの場合はfleetがあることを知る。

fleet

CoreOSは複数のホストOSでクラスターを組み、その上でコンテナを動かすが、どのコンテナを動かすのかというスケジューリングを担うのがfleet。Systemdライクなつくりになっていて、Unitファイルを使って定義を記述し、それに沿ってCoreOSが必要なコンテナを上げる。

複数コンテナが必要な場合はそのようにUnitファイルを書いてしまえばいいので、fleetでもDocker Compose的なことはできる気がした。むしろCoreOSだとDocker Composeがデフォルトで導入されていないので、スケジューリングにはfleetを使うのが前提になっているのかもしれない? また複数コンテナのネットワーキングも、flannelというのを使うとできるらしい。

Docker Compose

一方のDocker Composeはyamlファイルを使って、どのコンテナが必要か、各コンテナにポートやボリュームなどのオプションはどのように与えるかという部分を定義していく。

docker-compose.yml
version: '2'

services:
  elasticsearch:
    build: ./elasticsearch
    volumes:
      - /opt/esdata:/run/elasticsearch-2.3/data
    ports:
      - "9200:9200"
      - "9300:9300"

  kibana:
    build: ./kibana
    ports:
      - "5601:5601"

  fluentd:
    build: ./fluentd
    ports:
      - "24224:24224"

個人的にはこのやり方がしっくりきたいので、Docker Composeを今回は使っている。というのも、UnitファイルはどちらかといえばOS側の定義という印象が強いし、fleetはDockerとは明確に別サービスになる。一方composeのyamlファイルであれば、そのDockerサービスのレポジトリの中に含めてしまえるので、Dockerサービスをパッケージングしているという意識が強くなる気がした。従って今回のファイル構成は以下のようになっている。

.
├── docker-compose.yaml
├── compose-efk.env
├── elasticsearch
│   ├── Dockerfile
│   └── elasticsearch.yml
├── fluentd
│   ├── Dockerfile
│   └── fluent.conf
├── kibana
│   └── Dockerfile
└── README.md

ディレクトリのトップにdocker-compose.ymlを配置し、各コンテナの定義はフォルダを分けて、必要なDockerfileや設定定義ファイルを入れておく。サービス全体の構成はdocker-compose.ymlを見ればわかり、コンテナの設定はフォルダでまとめることができるので見通しがよくなる。

chroju/docker_elasticsearch: elasticsearch with alpine linux

Docker Composeはdocker-composeコマンドにより操作する。だいたいはdockerコマンドと似た感覚で扱うことができる。まとめられた複数コンテナによるサービスは「プロジェクト」とされ、docker-compose.ymlの所在するフォルダ名がプロジェクト名(変更したい場合は-pで指定が可能)となり、upstopといったプロジェクトに対する操作は、基本的にカレントディレクトリ上のdocker-compose.ymlで記載されたプロジェクトへの操作になる。別のファイルを操作する場合は-fでファイルを指定する。docker-composeコマンドはまた別でまとめたい。

# コンテナを作成して起動
$ docker-compose up
# 起動中のプロジェクトを確認
$ docker-compose ps
# プロジェクトを停止
$ docker-compose stop

Docker Composeの永続化

CoreOSはローリングアップデートで自動再起動がかかるので、サービスを永続化するために何かしらの仕掛けが必要になる。Docker Composeはあくまで、複数コンテナを協働させるためのサービスなので、スケジューリングには対応していない。

fleet

これについてもfleetがCoreOSだとデフォルトで使われる。Unitファイルを定義してサービス化したDockerコンテナをfleetに登録しておくと、CoreOSが落ちた場合はクラスタ内の別のCoreOS上でコンテナを継続稼動させてくれる。なのでUnitファイルにDocker Composeのコマンドを書いて、fleetにサービスとして登録する。

efk.service
[Unit]
    Description=efk
    After=docker.service

[Service]
    ExecStart=docker-compose -f /opt/efk/docker-compose.yml -p efk -d up
    ExecStop=docker-compose -p efk down

ただ、fleetにはsystemctl enableに相当するコマンドはないため、単体ホストにおける自動起動については、cloud-config.yml上で定義する必要がある。

cloud-config.yml
coreos:
  units:
    - name: "fleet.service"
      command: "start"
    - name: "efk.service"
      command: "start"

Docker Swarm

自分は使ったことがないのでなんともだが、Docker側が用意しているスケジューリングツールとしてはDocker Swarmが存在する。これもCompose同様にCoreOSには初期導入されていない。というのも、CoreOSは独自でetcdによるクラスタリングを行っているからだ。Swarmとetcdを並行導入して扱うというのが果たして行儀が良いのか、なんとも判断しかねるので今回はやめておいた。

DockerとCoreOSのエコシステムの相違

DockerにおけるSwarm、Composeと、CoreOSにおけるetcd、fleetがそれぞれ対応関係にあって、どちらを選んで実装するかが悩ましいように思えた。そもそもCoreOSは独自のコンテナ実装であるrktを開発しているので、Dockerのエコシステムとは別のものを進めたいのかとも思えるが、一方で両社はコンテナ仕様の統一も進めていて、よくわからない。

参考:Docker対抗のコンテナ型仮想化、CoreOSの「rkt 1.0」正式リリース。Dockerイメージも実行可能 - Publickey

現状コンテナを扱う上ではDockerがデファクト・スタンダードだとは思うが、エコシステムのベストプラクティスはどこから学べばいいのかというのが、まだ腑に落ちていない。

HashiCorp Vaultを機密情報データベースとして検証する

あまりまだメジャーではないみたいですが、HashiCorpが機密情報管理用のツールとしてVaultを出しています。代替になるツールもあまり思い浮かばないし、物は試しと使ってみました。

Vaultでできること概要

  • 機密情報等をKey,Value形式で書き込むと暗号化して保存してくれる。
  • Secret Backendsという機能で、MySQL、PostgreSQL、AWS、LDAP等と連携し、Vaultを通じてユーザー情報の追加、変更、削除等を行える。このときLease(期限)を設定し、一定期間後にアカウントを自動削除したり、パスワードをRevokeさせることができる。
  • デフォルトの状態ではデータはすべて暗号化(Sealed)されており、Vault自身も復号する方法を知らない。復号には分散鍵による認証が必要になる。
  • Vaultへのアクセス方法はCLIかREST API。
  • Vaultへアクセスする際の認証はユーザーパスワード形式、GitHub連携、一時的なtokenの払い出しなどを扱える。
  • Vaultに対して行われた操作はすべて保存され、監査に対応できる。

今回すること

  • 試験的な運用のため、Secret Backendsはデフォルトのまま、シンプルに情報の保存と読み出しだけを試す。 (Secret BackendsはデフォルトだとGenericというものがマウントされており、これは単純に情報を保存すると、それを暗号化してファイルに書き込むのみ)
  • ユーザーパスワード形式で認証して使用することを想定する。
  • 開発用途で起動できるdevモードが存在しているが、本番相当の設定を検証したいので今回は使わない。

初期設定

インストール

zipを落としてきてunzipし、PATHの通っている場所に置いて完了。

設定

hclもしくはjsonで書いた設定ファイルを元として起動する。設定ファイルは Server Configuration - Vault by HashiCorp に記述方法が書かれているが、最低限2つの項目が必要とされる。

backend

Vaultのデータを保存するSecret Backendの設定。MySQLやPostgresqlといったRDBMS、S3やDynamoDB、プレーンのファイルなど様々選べる。Genericを使う場合はfileを設定することになる。

listener

APIリクエストを受け付けるリスナープロトコルの設定。現状はtcpのみ対応。tlsを使う場合の証明書の設定等もできる。デフォルトではtlsが有効化されているため、使用しない場合は無効化の明示が必要となる。

tls無効化

  • listenerでtls_disable = 1を指定する。
  • vaultへの接続はデフォルトでhttpsが使われるので、VAULT_ADDR環境変数でhttpのアドレスを指定する。
vault.hcl
backend "file" {
  path = "/home/ec2-user/vault"
}

listener "tcp" {
  address = "127.0.0.1:8200"
  tls_disable = 1
}

起動

先のhclファイルを指定して起動する。起動後にvault initで初期化する。

注意点

  • フロントで立ち上がり、ログを標準出力に流してくるため、リダイレクトとバックグラウンドでの起動が必要。
  • vaultはmlockシステムコールを使用するため、管理者権限で実行しなければエラーになる。configの中でmlockの無効化もできるが、非推奨。
  • vault initした際に表示されるUnseal KeyInitial Root Tokenはいずれも必要なので記録する。
$ sudo vault server -config /etc/vault.hcl &>/var/log/vault &
$ vault init
Unseal Key 1 (hex)   : XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Unseal Key 1 (base64): XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
...
Initial Root Token: XXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Vault initialized with 5 keys and a key threshold of 3. Please
securely distribute the above keys. When the Vault is re-sealed,
restarted, or stopped, you must provide at least 3 of these keys
to unseal it again.

Vault does not store the master key. Without at least 3 keys,
your Vault will remain permanently sealed.

Unseal

VaultはデフォルトだとSealed(暗号化された状態)となっているため、先程出力されたUnseal Keyを使ってunsealを行う必要がある。まずステータスを確認してみる。

$ vault status
Sealed: true
Key Shares: 5
Key Threshold: 3
Unseal Progress: 0
Version: Vault v0.6.1

Sealed: trueとなっている。Key SharesUnsealのために共有しているキーの数であり、このうちKey Threasholdに表示された閾値の数だけキーを入力するとUnsealされる。

キーは複数人で分散管理し、全員が持ち寄ることで初めて復号化できる、というのがこの機構の目的。技術的には「シャミアの秘密分散法」と呼ばれるものらしい。逆にSealするときはvault sealコマンドを使えば一人で暗号化できるので、キーが漏れてしまった場合などはSealすることで対処できる。

復号化する。vault unsealコマンドを実行し、vault initの際に表示されたキーから3つを順に入れていく。出力のUnseal Progressが徐々に進んでいき、Thresholdに達するとunsealされる。

$ vault unseal
Key (will be hidden):
Sealed: true
Key Shares: 5
Key Threshold: 3
Unseal Progress: 1

Auth

認証機構。デフォルトで使用できるユーザーはrootのみなので、まずはrootで認証する。vault initのときに出力されたrootのトークンを使う。

$ vault auth XXXXXXXXXXXXXXXXXXX
Successfully authenticated! You are now logged in.
token: XXXXXXXXXXXXXXXXXXXX
token_duration: 0

使用するAuth Backendを有効化する。今回はuserpass。有効化後に、ユーザーを作成して認証までしてみる。

$ vault auth-enable userpass
Successfully enabled 'userpass' at 'userpass'!

$ vault write auth/userpass/users/chroju password=******
Success! Data written to: auth/userpass/users/chroju

$ vault auth -method=userpass username=chroju
Password (will be hidden):
Successfully authenticated! You are now logged in.
The token below is already saved in the session. You do not
need to "vault auth" again with the token.
token: XXXXXXXXXXXXXXXXXXXXX
token_duration: 2592000
token_policies: [default]

認証が成功するとtokenが発行されるが、APIを使うときはこのtokenで認証することができる。CLIではこの状態でもう操作が可能。なおtokenはカレントディレクトリの.vault-tokenファイルに書き込まれており、CLI使用時はこれでセッションを維持しているっぽい。

ACL

単にユーザーを作っただけでは、すべてのリソースに対する操作権限はDenyに設定されているため、ユーザーがどのリソースに対してアクセス可能かはACLで定義する。Vaultにおける機密情報の保存場所は、いわゆるファイルパスのようなスラッシュで区切った形式で指定する。従って適切にフォルダを分けて、アクセス権を分散させることができる。

  • ACLはHCLもしくはjsonにより記述する。
  • フォルダ配下へのアクセスは自動で付与されない。例えばpath "secret"に対する許可を書くと、secretにはアクセスできてもsecret/fooにはアクセスできない。
  • ACLを付与しない限り、デフォルトで全アクセスはDenyされる。
  • userpassバックエンドでの認証はauth/token/lookup-selfへのread権限がなければ通らなかった。少し謎。
path "auth/token/lookup-self" {
  policy = "read"
}

path "secret/*" {
  policy = "write"
}
$ vault policy-write allow-secret /etc/vault_acl.hcl
$ vault write auth/userpass/users/chroju password=****** policies=allow-secret

利用

データの読み書き。

$ vault write secret/foo value=bar
Success! Data written to: secret/foo

$ vault read secret/foo

普通に入力するとコマンドラインの履歴に残って気持ち悪いので、標準入力やファイルからの入力を駆使する。

# 標準入力を使う
$ echo -n '{ "username":"foo", "password":"bar" }' | vault write secret/server01
Success! Data written to: secret/server01

# jsonファイルを使う 
$ cat server02.json
{
  "username" : "foo",
  "password" : "bar"
}

$ vault write secret/server02 @server02.json
Success! Data written to: secret/server02

APIからアクセスする。tokenをX-Vault-Tokenヘッダーに入力することで認証する。

$ curl http://localhost:8200/v1/secret/foo -H "X-Vault-Token: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
{"request_id":"b745ef53-ff32-9ffe-b8b2-a7dc03072f7e","lease_id":"","renewable":false,"lease_duration":2592000,"data":{"value":"bar"},"wrap_info":null,"warnings":null,"auth":null}

tokenはtoken-createコマンドを使って、一時的な払い出しができる。オプション-ttlで時間制約を持たせることができ、-use-limitで使用回数を制限できる。

$ vault token-create -policy=allow-secret -ttl=1h
Key             Value
---             -----
token           bcae0b31-55af-8349-dd9a-f0200fbe6b53
token_accessor  39be2d81-4860-d271-365d-42db4db41f0e
token_duration  1h0m0s
token_renewable true
token_policies  [allow-secret default]

その他留意点など

パスワードの変更

userpass backendを使う場合、当然各ユーザーがパスワードの変更をしたくなるが、これもまたvault writeコマンドによる書き込みになる。パスワード情報はauth/userpass/users/${username}/passwordというパスに保存されているため、このパスに対してACLで許可を与えなくては、パスワード変更はできない。

$ vault write auth/userpass/users/chroju/password username=chroju password=hogefuga
Success! Data written to: auth/userpass/users/chroju/password

しかし、そうなると各ユーザーに個別にACLを設定して、自分のパスワードパスだけに対するアクセス権を与えてやることになるので、ACLの種類がユーザーの数だけ増えて管理が大変になりそう。この点、もう少しいいソリューションはないのかなと思う。

雑感

  • APIアクセスが主であり、また認証(Auth Backends)もuserpassではなくGitHub等の他サービスと連携させることが基本なのかなと思う。先に書いたパスワード変更の件など、userpassでCLIを介して常時使うような設計にはなっていない気がした。
  • とはいえHashiCorpのツールらしく、各機能がシンプルに独立していて、プラガブルに使える点はとてもいい。「こうしなければ使用できない」となってしまうような変な制限がなく、使い方に幅がある。それだけに運用方法は練る必要がある。
  • スクリプト等でパスワードをハードコーディングするバッドプラクティスへの対策として、Vaultからパスワードを引っ張ってきたりできると思うので試したい。が、基本的にtokenにTTLがある状態で、どう設計すればいいのかよくわからない。軽くググってもいまいち事例が出てこないし、出てきたと思ったら唯一TTLの存在しないrootアカウントのtokenを使ってたりしてセキュリティ的によろしくない感じであった。

CircleCIでmarkdownからスライド資料を自動生成する

先日初めてLTというものをする機会がありましたので、せっかくだし楽にスライド資料を作れる仕組みを作ってしまおうと思ったのが動機になります。

実現したかったこと

  • markdownで資料を作成してgit pushするとCircleCIがスライド資料に起こしてくれる。
  • スライド資料はGitHub Pagesで公開され、そのまま発表に使える。
  • ついでにCircleCI上で文法上の誤りもテストしてくれる。

実現方式

markdownからのスライド生成

reveal.jsが有名かと思いますが、それをRubyGem化して、コマンド一発だけでmarkdownからスライド資料への変換を可能にしたreveal-ckを使います。

markdownはほぼGitHub Flavored markdownで大丈夫(絵文字もOK)ですが、---でページ区切りになるという点だけ覚えておけばよいかと。

GitHub Pagesへの公開

2016年8月のアップデートで、GitHub Pagesへの公開方法が以下の3つになりました。

  • masterブランチの/docsフォルダ内をPagesとして公開する。
  • masterブランチをPagesとして公開する。
  • gh-pagesブランチをPagesとして公開する(従来の方法)。

スライド用に生成されたファイルをフォルダに押し込める形で実現できそうなので、当初は/docsフォルダを使う方法を検討していましたが、スライドのGitHub Pages上での公開は発表のときだけ使えればよかったので、そういう一時的な用途でmasterブランチを使うのは微妙だなと見直し。またCircleCIからmasterブランチを書き換えるというのも抵抗があったので、CircleCIではgh-pagesブランチをcheckoutさせる形にしました。

文章校正

最近流行りの分野ではありますが、定番のtextlintを使いました。textlint自体初めて使ったということもあり、以下の記事を大いに参考とさせていただきました。

実装

レポジトリ内は以下の構成になりました。

|-- .textlintrc
|-- circle.yml
|-- package.json
`-- (スライド用ディレクトリ)
    |-- slides.md
    `-- config.yml

トップにはCircleCIを設定するためのcircle.ymlと、textlintを導入するためのpackage.json、textlintの設定を書いた.textlintrcの3ファイルを置きます。package.jsonと.textlintrcの設置については先のリンク記事の内容ほぼそのまんまですが、以下の手順で生成しています。いずれもレポジトリのトップの場所で実行します。

# package.jsonを生成します。
$ npm init
# textlint等をインストールし、package.jsonに依存関係を記述。
$ npm install --save textlint textlint-rule-common-misspellings textlint-rule-preset-japanese
# node_modulesにtextlint等がインストールされますが、
# 実際はCircleCI上で逐次インストールして使うので、レポジトリからは削除していいです。
$ rm -rf node_modules
# package.jsonのscriptsにtextlint実行用のコマンドを記載しておきます。後述。
$ vim package.json
# .textlintrcは手動で生成して、後述の通り記載します。
$ vim .textlintrc

package.jsonは以下のように、scriptsにtextlint実行用のコマンドを記載しておきます。これでnpm run textlint (対象ディレクトリ)で、指定したディレクトリ内のmarkdownファイルに対してtextlintが実行されます。

package.json
{
  "private": true,
  "scripts": {
    "textlint": "textlint -f pretty-error"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/chroju/talks.git"
  },
  "dependencies": {
    "textlint": "^7.1.2",
    "textlint-rule-common-misspellings": "^1.0.1",
    "textlint-rule-preset-japanese": "^1.3.3"
  }
}

.textlintrcには適用する校正ルールを記載します。

.textlintrc
{
  "rules": {
    "common-misspellings": true,
    "preset-japanese": true
  }
}

続いてcircle.ymlは以下の通り。

circle.yml
general:
  branches:
    ignore:
      - gh-pages
      - master

machine:
  timezone:
    Asia/Tokyo
  ruby:
    version:
      2.3.1
  node:
    version:
      7.0.0

dependencies:
  cache_directories:
    - "/opt/circleci/.rvm/gems"
    - "/home/ubuntu/talks/node_modules"
  pre:
    - gem install reveal-ck --no-ri --no-doc
    - npm install

test:
  override:
    - npm run textlint $CIRCLE_BRANCH
  post:
    - cd /home/ubuntu/talks/$CIRCLE_BRANCH && reveal-ck generate && cp -r slides/* ../

deployment:
  github:
    branch: /.*/
    commands:
      - git config --global user.name chroju
      - git config --global user.email chor.chroju@gmail.com
      - rm .ruby-version
      - git checkout gh-pages
      - git add --all
      - git commit -m "Circle CI deploy"
      - git push git@github.com:chroju/talks gh-pages

まずdependenciesでreveal-ckとtextlintをそれぞれインストールしています。textlintに関してはpackage.jsonに依存関係を書いているので、npm installだけで入ります。一度インストールしてしまえばその後は使いまわせるので、cache_directoriesに指定してキャッシュしておきます。

参考:Caching RubyGems Install - Programming Languages - CircleCI Community Discussion

testではnpm run textlint $CIRCLE_BRANCHでoverrideし、textlintを実行します。失敗するとこんな感じに。

textlint failure

なお、overrideに記載している通り、textlintの対象ディレクトリ名は、$CIRCLE_BRANCH変数でブランチ名から取ってきています。つまり新たにスライド資料を作成するときは、以下の手順になります。

  1. ブランチを新しくつくる(ブランチ名は発表するイベント名や、スライドタイトルがオススメ)。
  2. ブランチ名と同名のディレクトリを作成する。
  3. 作成したディレクトリ内にslides.mdを置き、資料を作成する。

testpostでは、このブランチ名のディレクトリに移動してreveal-ck generateを実行し、スライド資料を作成しています。作成されたファイル群はslidesフォルダの中に置かれますが、これをトップへcpしているのは、GitHub Pagesで公開したときにトップのパスで資料が見られるようにするためです。

textlintが通ればdeploymentに移行し、gh-pagesブランチを生成してpushして完了です(逆に言えばtestが通らなければ、deploymentは実行されません)。ここまで無事に終われば、GitHub Pagesでスライド資料が見られるようになっているはずです。ブラウザでアクセスして、全画面表示にすればそのまま発表に使えます。

発表が終わったらgh-pagesブランチは削除し、スライド用ブランチをmasterにマージしてあげれば完了です。

補足

スライドの外観を変更したい

reveal.jsのスライドの外観は、slides.mdと同じ場所に置いたconfig.ymlによって設定できます。主にテーマ(色やフォント)とtransition(ページめくりのアニメーション)が変更でき、テーマの一覧は http://lab.hakim.se/reveal-js/#/themes 、transitionの一覧は http://lab.hakim.se/reveal-js/#/transitions から確認できます。またtitleを指定すると、GitHub Pagesで開いたときのページタイトルがそれになります。

config.yml
theme: "white"
transition: "none"
author: "chroju"
title: "ポエム駆動開発"

スライドをpdfで保存したい

これはreveal.jsが機能を持っています。GitHub Pagesでスライドのトップを開いたら、URL末尾にindex.html?print-pdfを付加して開き直し、印刷画面から「PDFで保存」します。保存するとき、ページの向きは「横」にします。

参考:reveal.js で pdf 印刷

完成品

GitHubに上げています。

chroju/talks: LTs

masterブランチのものは私のLT資料を入れているので個人用ですけど、個人的なものを削除したreleaseブランチを用意しているので、そちらをフォークしてもらえればここに書いたとおりのことがすぐに実行できるはずです。詳しくはREADME.md参照で。

influxDB + Grafanaに入門する

背景

可視化ツールとしてはElasticsearchを常に使っていたのですが、いわゆるサーバーのメトリクスデータのような数値データを記録するのであれば、influxDBというのもあるということでお試し。

influxDB + Grafanaの概要

influxDB

influxDBは時系列DB (Time series database) と呼ばれるソフトウェアに分類される。時系列DBはその名の通り、時間を追うに従って変化するようなデータを格納する機構を備えたDBで、英語版Wikipediaだと記事ページがあるが、これによればRRDToolも時系列DBとされるよう。

RRDToolとの比較も考えつつ、influxDBの特徴を上げると以下のような点。

  • データ登録はREST APIを通じてjsonで可能。
  • デフォルトで認証機構を備えている。
  • クエリはSQLに準じた文法を使用することが可能。
  • Web UIを備えており、GUIでインタラクティブにクエリ結果を確認できる。

Grafana

GrafanaはKibanaからフォークされて作られたダッシュボードツールですが、influxDBに特化したものではなく、GraphiteやCloudWatchとも連携ができる。こちらもデフォルトで認証機構を備えている。

手順

influxDBとGrafanaの起動

手っ取り早いので公式のDockerイメージをDocker Composeで立ち上げました。

2016-12-22 追記: Grafanaのコンテナ設定にData Volumeの記述を追加。Grafanaのダッシュボード、ユーザー設定等を永続化するために、sqliteのディレクトリを保存しておく必要があったため。ダッシュボード設定等はMySQLなどの外部DBに保存させることも可能だが、簡略化の意味で、今回はデフォルトで用意されるsqliteを使用することにした。(参考: Grafana - Configuration

docker-compose.yml
version: "2"

services:
  influxdb:
    image: influxdb
    ports:
      - "8083:8083"
      - "8086:8086"
    volumes:
      - /tmp/influxdb:/var/lib/influxdb

  grafana:
    image: grafana/grafana
    ports:
      - "3000:3000"
    volumes:
      - /tmp/grafana:/var/lib/grafana

influxDBで開いている2つのポートのうち、8083がWeb UIで8086がREST APIのエンドポイントに当たります。またデータは/var/lib/influxdbに保存されるので、データボリュームとしてホストOSと同期させています。

GrafanaはWeb UI用のポートを開けるだけ。Kibanaの場合はkibana.ymlでElasticsearchとの接続設定をしていましたが、GrafanaはWeb UI上でDBとの接続を設定するので、この時点では特に設定ファイルなどの考慮は不要です。

influxDBとGrafanaの接続

http://(Docker HostのIP):3000にウェブブラウザからアクセスし、左上のアイコンから「Data Sources」を選び、「Add Data Source」からhttp://(Docker HostのIP):8086へ接続します。

Grafana Add Data Source

なお認証ユーザーIDとパスワードの初期設定は、influxDBがroot:root、Grafanaがadmin:admin。

influxDBへのデータ投入

今回はPythonを使いましたが、その前に前提としてinfluxDBの構造の話。

influxDBの構造

  • Database : RDBMSにおけるデータベースと同様、最も大きなデータ単位。Databaseはデータ投入前に作成しておく必要がある。
  • Measurement : RDBMSにおけるテーブルの位置。
  • Retention Policy : データの保存期間を定めたDURATIONと、influxDBクラスタ内でいくつデータのコピーを保持するかを定めたREPLICATIONからなるデータの保存ポリシー。基本的にはDatabaseに対して紐付ける形で使用する。

まだお試しなのでちゃんと使っていないのですが、Retention Policyがあるのがいいですね。データを無制限に保存して容量が溢れてしまうような事象を防げるのと、可用性を担保できるのと。

influxDBのデータ構造

Elasticsearchだとjsonを適当に突っ込むとそれでもう使えたので、APIから取得したjsonをそのまま投げるようなズボラ運用もできたんですが、influxDBの場合はjsonに含むべきデータ構造が決められています。

  • measurement : どのmeasurementにインポートするかを指定。
  • fields : いわゆるデータそのものをキーバリューの形式で指定する。キーは文字列型限定ですが、バリューは文字列の他にfloat, integer, booleanにも対応しています。また複数のfieldを含めることも可能。
  • tags : オプショナルなデータを指定する。例えばサーバーのメトリクスデータを入力しているのであれば、サーバー名を指定するのがtags。こちらも複数含めることはできますが、文字列型のみに対応している点に注意。
  • timestamp : データのタイムスタンプ。時系列データの要と言ってもよい部分。指定しなかった場合は入力処理が行われた時刻が自動的に適用されます。なお、influxDBは UTCのみ対応です。

fieldstagsの使い分けが若干恣意的なところがありそうですが、大きな違いとしてfieldsはインデックスされませんが、tagsはインデックスされます。従ってクエリをかけるときに例えばGROUP BY句を使ったりすると思いますが、ここで使用するのはtagsの方です。検索等に使うメタデータがtagsで、時系列で変化するデータがfieldsと押さえればよいかと。変化するデータ、例えばCPU使用率が80%のとき、などというクエリは普通かけないので、そう考えるとfieldsがインデックスされないというのは納得ですね。

データ投入処理

簡単にソース示します。

from influxdb import InfluxDBClient

client = InfluxDBClient('127.0.0.1', 8086, 'root', 'root', 'sample')

# databaseの存在を判定し、作成前であれば新規作成
dbs = client.get_list_database()
sample_db = {'name' : 'sample'}
if sample_db not in dbs:
    client.create_database('sample')

# インポートするjsonデータを作成
import_array = [
{
  "fields" : {
    "cpu" : 50.0,
    "mem" : 20.0,
  },
  "tags" : {
    "category" : "fuga",
    "machine" : "web02"
  },
  "measurement" : "metrics"
}
]

# データ投入
client.write_points(import_array)

簡単です。InfluxDBClientに直にパスワード書いていますが、本運用する際はハードコーディングはやめるべきところです。データのjsonについては先程も書いたとおり、fieldstagsを複数入れてみています。これでMachineごとのグラフを描いたりすることができます。

Grafanaでの可視化

GUIでの操作になるので詳細は割愛しますが、KibanaのようにDashboard上にPanelと呼ばれるパーツを配置していく形で可視化していきます。PanelにはGraphのほか、最新の値だけを数字で大きく表示させたりできるSingle stat、Markdown等で文章が書けるTextといったものもあり、自由度は高いです。

またGraph等で表示するデータを指定する際は、SQL文で設定するため、わかりやすい一方でinfluxDBにおけるSQL文の使用方法は押さえておく必要があります。とはいえGUI上で候補をクリックしながら組み合わせてSQLを作れるので、多少曖昧な理解でもなんとかなります。とても楽です。

使用感、ES+Kibanaと比べて

Elasticsearch + Kibanaの使用感をイメージすると、インプットするときに設定する必要のあるjsonのキーが定められていたり、勝手が違うところに戸惑いはしましたが(というよりはjsonなら何でも突っ込めて程々に検索できちゃうESが神)、使用感としてはやはり「簡単にデータの可視化ができる」、しかも見やすいという点では一致しています。はじめはElasticsearchと競合するのかな?と思っていましたが、現在では両者は明確に異なる守備範囲を持ったツールとして理解しました。

  • influxDB : 文字通り時系列データであり、時を経て「変化」する値に関し、その変化を可視化する点に特化されている。fieldsには文字列も入力できるが、インデックスされていないため検索用途には向かない。
  • Elasticsearch : Kibanaでの可視化、グラフ描画が便利なので忘れていたが、こちらは「全文検索エンジン」。投入した大量の文字列データから検索をかけたい場合にはこちら。

例えばMySQLを運用するにあたり、スロークエリログを長期間取り溜めて解析を行いたい場合にはElasticsearchが適切ですし、トラフィック量を監視したいのであればElasticsearchでも可能ではありますが、棲み分けとしてはinfluxDBということになりそう。

個人でサーバーレスっぽいことやろうとしてピタゴラスイッチになった話

※追記アリ: サービス仕様の変更により、すでにこの話は古くなっています。

長年独自ドメインの下でプロフィールサイトをVPSに置いていたのですけど、いまどき静的サイトを返すのに自力でnginx動かす必要もなさそうだし、VPSの料金もかかってるし、AWSでなんとかしようと考えました。

さらに、せっかくだからサーバーレスアーキテクチャーっぽくしようと思い、「サイトにアクセスが来たとき、Lambdaで自ブログのRSSを取得して、ページに最新記事のリンクを埋め込む動的な構成にしてみよう」と考え、AWSでちょっと動的なページを作ろうとしたわけなのですが、当初想定していた以上にいろいろ大変だった、という記録です。

ぼくの考えた設計(第1形態)

serverless_first.png

はじめはわりと簡単にできるやろ〜と気楽に考えてました。こんな感じで。

  • API GatewayからLambdaにつなげ、LambdaでRSSを読み込み、動的にページを生成して返す。
  • 独自ドメインを使いたいので、Route53でAliasにAPI GatewayのURLを指定することで遷移させる。

問題点

まず最初の想定外として、API GatewayにHTTPでアクセスができなかったこと。

  • API GatewayはHTTPSアクセスしかできないため、独自ドメインで運用する場合は証明書を取得しなければならなかった。
  • 証明書にお金を払いたくはなかったのでAmazon Certificate Managerでリクエストしようとした。
  • しかし ACMの証明書はCloudFrontとELBにしか対応しておらず、API Gatewayに直接適用できなかった。 (2017-03-10追記:ACMがAPI Gatewayに対応しました→ Amazon API Gateway Integrates with AWS Certificate Manager (ACM) もしやりたければCloudFrontに証明書を適用した上で、CloudFrontのoriginとしてAPI Gatewayを指定する必要があった。
    • おまけにAPI Gatewayは内部でCloudFrontを利用しているのでなんかダサイ。

まぁとはいえ引き下がるのも何なので、やむを得ずCloudFront→API Gatewayの二重構成にしようと考えました。そのためにACMで証明書をリクエストしようとしたのですが、ここでさらに問題発覚。

  • ACMで証明書をリクエストする場合、ドメインの認証にはドメインに対して送信されたメールを受信する必要があるため、Route53→SESを連携してメールを受信しなければならなくなった。

もちろん、元々VPSに対してアクセスさせていたドメインなので、VPS内でメール受信をすればいい話でもあったのですが、この時点で気が早いことにDNS設定を変更済みだったため、またVPSへフォールバックするのも面倒になり、SESを使うパターンを採用することに。

ここで改めて全体を設計し直します。

ぼくの考えた設計(第二形態)

serverless_second.png

個人の、しかもほとんどアクセスのないプロフィールサイトなのでサクッと作るつもりが、だいぶ構成要素が増えてしまいました。Route53→CloudFront→API Gatewayを経由してLambdaをキックしてHTMLを取得。CloudFrontにはACMの証明書を適用し、HTTPSを有効化。さらにRoute53のMXレコードでSESへメール受信させる設定。書いてないですけど、受信したメールはS3バケットに蓄積させています。

たぶん、ちゃんと設定すればこれで動きます。が、なんか完成させられず。。

問題点

  • CloudFrontからHTTPSを返すAPI Gatewayの連携がなんか上手く行かなかったようで、403が返ってきた。。
  • というかもはやAPI GatewayからHTTPSを返すことにこだわる必要ないんでは、アクセスがあったときに動的なページ生成するんじゃなくて、あらかじめLambdaで静的ページ作っておいてS3に置いとけばいいんではと思うに至った。

んで最終形態へ。

ぼくの考えた設計(最終形態)

serverless_third.png

サービスの数は相変わらず多いですが、構成はシンプルになった気がしています。

  • Lambdaファンクションは生成したHTMLをreturnするのではなく、S3バケットに保存する形に変更。
  • このLambdaをAPI Gatewayに設定し、自ブログの更新があったことをトリガーに、IFTTTのMakerチャンネルで叩かせる。これにより、ブログ更新タイミングで最新記事リンクが掲載された静的ページが作られる感じになる。
  • Route53→CloudFrontは、先のLambdaで静的ページを保存したS3バケットをoriginにする。

そもそもにして、例えば天気の情報のようなリアルタイム性の高いコンテンツでなければ、動的にページ生成する必要もないわけで。最初から気付けよという感じですが、まぁ動的なページ生成やってみたかったんだもの。

得られた教訓

  • 現状のサーバーレスな構成はどう足掻いてもマネージドサービスの仕様に縛られる。なんとなく理解して「こんなことできそうだな」と思っても、細かく仕様を確認すると実はできない、みたいなことは往々にしてある。
  • ピタゴラスイッチ的に「頑張っていろんなサービスを繋げて目的を成し遂げる」ことへの違和感がある。例えば今回、CloudFrontは本質的機能であるCDNは必要なく、API GatewayとACMを結びつけるためだけに利用しているという本末転倒感がなんとなく気持ち悪い(ここでいうピタゴラスイッチは、事物を本来の目的外の用途で使用し、組み合わせて一つのことを成そうとする、的な意味です)。
  • 取りあえず作り始めて、仕様が想定と異なったから設定を変えて、別のサービスを新しく組み入れて、という作業をマネジメントコンソールで進めると、あっという間に構成がブラックボックスになる。おそらく、最初からTerraformなりServerless Frameworkなりといったデプロイツールを経由して組み立てるのがベター。

今回は個人でのニーズだったのでよいのですけど、企業でこれと同じことをやる場合、RSSの取得だけならフロントエンドで処理させる手もありますし、繋ぎ合わせた複数のサービスを管理するより、1台のウェブサーバーを管理する方がコストが低いという判断もあると思います。

手段と目的を逆転させずにAWSを活用する視点は忘れないようにしたいものだな、というのが今回の感想です。

参考

esa.ioで書いた記事をQiitaへマルチポストする

個人で使っているesa.ioでブログやQiitaの下書きをしているのだが、その後アップロードするときに「手でコピペ」しているのが面倒になり、Webhookを使った自動マルチポストを実装してみた。

要件

  • esa.ioで新しく「#qiita」タグの付いた記事を作成したとき、Webhookでesa.io記事作成者(screen name)と同名のユーザーでQiitaへ同じ記事を投稿する。
  • esa.io上ではディレクトリ構成を含んだ「path/to/article title」というタイトル形式を取るが、アップロード後はディレクトリ部分を除いた「article title」のタイトルにする。
  • タイトルが同じ記事がすでにQiitaにある場合は投稿しない。
  • esa.ioで設定していたタグをQiitaの記事にも同様に設定する。
  • 投稿後にQiita記事のURLをSlackへ通知する。

slack image

実装

実装には Chalice を使った。Chaliceはいわゆるサーバーレスアプリケーションを実現するためのフレームワークで、簡単にLambda + API Gatewayを活用したアプリケーションをデプロイできる。

本来であれば複数のメソッドを実装し、いわゆるRESTfulなアプリケーションを実現できるわけだが、今回はWebhookで叩くエンドポイントが欲しいだけなので、メソッドは1つのみ。

GitHub上で公開したので、README.mdに従って利用すれば誰でも使えるはず。→ chroju/esa_then_qiita: esa.io posts cross post to Qiita

app.py
# -*- coding: utf-8 -*-
import json
import os
from chalice import Chalice
import requests

app = Chalice(app_name='esa_then_qiita')
app.debug = True
API_URL = u"http://qiita.com/api/v2/"

filename = os.path.join(os.path.dirname(__file__), "chalicelib", "config.json")
with open(filename) as f:
    config = json.load(f)
API_KEY = config["QIITA_API_KEY"]
SLACK_HOOK_URL = config["SLACK_HOOK_URL"]
SLACK_CHANNEL = config["SLACK_CHANNEL"]

@app.route('/qiita', methods=['POST'])
def index():
    # parse request
    request = app.current_request.json_body
    raw_title = request["post"]["name"].split(u"/")[-1]
    username = request["user"]["screen_name"]
    title_and_tags = [ i.strip() for i in raw_title.split("#") ]

    # find qiita tag
    if u"qiita" not in title_and_tags[1:]:
        return u"nothing to do (this post is not for qiita)"

    # check articles duplication
    past_items = requests.get(url=API_URL + 'items?page=1&per_page=20&query=user%3A' + username)
    past_titles = [ item["title"] for item in past_items.json() ]
    while "next" in past_items.links:
        past_items = requests.get(past_items.links["next"]["url"])
        past_titles.extend([ item["title"] for item in past_items.json() ])
    if title_and_tags[0] in past_titles:
        return u"nothing to do (same title post already exists)"

    # set up qiita request
    qiita_input_dict = {
        "title": title_and_tags[0],
        "body": request["post"]["body_md"],
        "gist": False,
        "private": False,
        "tweet": False,
        "tags": [ {"name": tag} for tag in title_and_tags[1:] if tag.find(u"qiita") == -1 ]
    }
    headers = {
        "Authorization": u"Bearer " + API_KEY,
        "Content-Type": u"application/json",
        "Accept": u"application/json"
    }

    # post qiita
    r = requests.post(url=API_URL + "items", data=json.dumps(qiita_input_dict), headers=headers)

    # post result to slack
    slack_input_dict = {
        "text": u"esa.io -> qiita done.\n{}".format(r.json()["url"]),
        "channel": SLACK_CHANNEL
    }
    if r.status_code == 201 and SLACK_HOOK_URL != "":
        requests.post(url=SLACK_HOOK_URL, data=json.dumps(slack_input_dict))
    return r.text

chaliceについて

chaliceは今回初めてきちんと使ったけど、API Gateway + Lambdaを扱う上では非常に楽な選択肢の1つだと感じた。

  • local実行モードがあるので、debugが楽。
  • debugにあたってはlogging機能も使える。
  • chalichelibというディレクトリに入れたファイルはすべてパッケージングしてデプロイしてくれる。大規模なPythonアプリケーションだったり、変数を外に出しておきたい場合だったり、様々な用途に活用できて幅が広がる。
  • 難点としてpython2.7であること。Lambdaが3.x非対応である以上、仕方なしか。

応用

esa.ioを複数人で使用している場合

ここに記載したのはあくまで自分の「個人esa.io」用なので、QiitaのAPIキーも1つしか埋め込んでいない。複数人でesa.ioを使っている場合(その方が多数派ですよね)は、APIキーをdictで持たせておけばよいかと。

config.json
{
  "QIITA_API_KEY": {
    "foo": "295cdXXXXXXXXXXXXX",
    "bar": "b21acXXXXXXXXXXXXX",
    "baz": "8c1d3XXXXXXXXXXXXX"
  },
  "SLACK_HOOK_URL": "https://hooks.slack.com/services/xxx",
  "SLACK_CHANNEL": "channel"
}
app.py
...
    headers = {
        "Authorization": u"Bearer " + API_KEY[username],
        "Content-Type": u"application/json",
        "Accept": u"application/json"
    }
...

他サービスへの連携

もちろんQiitaだけではなく、同じ形で他のサービスへの投稿もhookできるはず。とりあえず自分の使用範囲でgithub.ioへのブログ投稿も自動化したいのだが、hugoを使っているのでCircleCI上でhugoコマンドを使うとか、一手間要りそう。

influxDBとGrafanaで手の届かないところ

influxDBとGrafanaで様々なメトリクスグラフを作っていて、基本的には気に入ってはいるのですが、ちょいちょい痒いところに手が届かないのでまとめておきます。

influxDBとGrafanaの基本については こっち 参照で。なお用途としてはAPI叩いた結果を格納したり、サーバーからメトリクスを上げさせて格納したりということをしています。

RP, CQによるダウンサイズとGrafanaの付き合い方

influxDBにはContinuous QueriesとRetention Policiesという機能があり、これを利用したデータの定期的なダウンサンプリングが公式でも推奨されている。

Continuous Queries (CQ)

CQは名前の通り、一定間隔で継続的にクエリを実行する機能。これを利用することで、1分刻みでデータが補完されたmeasurementから、10分平均値を定期的に抜き出し、別のmeasurementにinsertすることができる。

Retention Policies (RP)

RPは、各measurementのデータ保存期間を設定する機能。先の1分刻みデータを保存したweek.datameasurementは1週間しか保存しない設定にし、10分平均のデータはyear.datameasurementに1年間保存する設定にすれば、ダウンサンプリングされたデータだけを長期保存して、ディスク容量を空けることができる。

1分刻みのデータをずっと持っておくのもツライので、基本的にはこの機能を使うことになる。

が、Grafanaにおいて。GrafanaのグラフはinfluxDBに対するクエリを設定して、その結果を表示してくれるものである。従って1分刻みデータからSELECT .... FROM week.data ...というクエリをかけたグラフを作成した場合、表示する時間範囲を1か月に引き伸ばしても、1週間より前のデータはyear.dataにあるので、データがきちんと表示できない。

Grafanaは表示するグラフの時間範囲を自由に指定できるので、心情としては勝手にダウンサンプリング先のデータを読むようにできたらよいのだが、今のところこの回避策は見つかっていない。というかissue挙がっているので基本的には現状ムリっぽい。

Continuous Queriesでワイルドカード指定するとfield keyが変化する

これはinfluxDBの仕様なのだけど、select文でAggregationを行なったとき、出力のカラムタイトルはAggregationの機能名になる。

> SELECT mean(value) FROM default.loadaverage WHERE time < now() - 10m GROUP BY time(5m) LIMIT 5
name: loadaverage
time                mean
----                ----
1491868800000000000 1.24320000000000003
1491869100000000000 1.28506666666666663
1491869400000000000 1.2694666666666667
1491869700000000000 1.21906666666666666
1491870000000000000 1.21386666666666665

これが嫌な場合には回避策があって、mean句の後ろにAS句を使えば、その名前をカラムタイトルにできる。

しかしContinuous Queryをかけるとき、あるmeasurementのfieldsを一括して集計するためにワイルドカード指定を使うと、このASがprefixの指定になってしまう。

> SELECT mean(*) AS hoge FROM default.loadaverage WHERE time < now() - 10m GROUP BY time(5m) LIMIT 5
name: loadaverage
time                hoge_value
----                ----
1491868800000000000 1.24320000000000003
1491869100000000000 1.28506666666666663
1491869400000000000 1.2694666666666667
1491869700000000000 1.21906666666666666
1491870000000000000 1.21386666666666665

どうも今のところこういう仕様っぽいのでどうしようもない。回避するには、ワイルドカードを使わずに1つずつCQを作り、それぞれでAS指定を行なっていくしかない。

Templatingで有効なtag valuesだけを拾いたい

Grafanaの Templating は、influxDBへの特定クエリ結果を変数として扱える機能。例えばshow tag values with key = "hostname"でホスト名の一覧をピックアップし、そこから任意のホスト名を選択して、そのサーバーのグラフを表示させる、といった使い方ができる。

クラウド環境のサーバーメトリクスを収集していると、当然サーバーが頻繁に削除されたりするわけだが、今のところTemplatingには「現在activeなサーバー」だけをピックアップする方法が無さそうで、influxDBに保持された全サーバー名がピックアップされてしまう。手段として考えられるのは、

  • value >= 0のような指定で、現在fieldに値を持ったサーバー名だけピックアップさせる。
  • 時間範囲を直近1日等で区切って、その間に出力されているサーバー名だけをピックアップさせる。

といったところだが、今のところいずれもshow tag valuesでは構文として書くことができない。GitHub issuesで挙げられてはいるので、取りあえず対応待ち。

なお、先のissueでワークアラウンドとして挙げられているのが、Continuous Queryで最新のサーバーリストを別のmeasurementに作ってしまうこと。これは確かに有効な手法ではある。

CREATE CONTINUOUS QUERY "lookupquery" ON "prod" BEGIN SELECT mean(value) as value INTO "lookup"."host_info" FROM "cpuload" where time > now() - 1h GROUP BY time(1h), host, team, status, location END;

以上、ちょこちょこツライポイントに遭遇してはいるが、全体的には程よく使えている状況である。

Amazon Echo (Alexa) のSkillの開発に必要な基本概念を押さえる

Amazon Echo Dotを買ったので、早速Skillを自分で作るなどしてみたのだが、そのとき調べたことを書き記しておく。だいたい入門としてはこれぐらいの内容があれば足りるはず。

Alexaの開発についてはわりと資料が充実していて、公式のドキュメントもすでに日本語になっているし、ビデオを交えたトレーニング資料も存在している(現時点ではまだ途中までの公開)。

また国内販売開始からは日が浅いものの、それ以前にすでに開発を試している方々がちらほらいらっしゃるので、参考にさせていただいた。少し画面についてはこれらの記事当時から変わっていたりするので注意。

Alexa Skillの開発

まず概念的な話から。

Alexaはデフォルトで多様な機能を持っているわけではなく、「Skill」と呼ばれるデベロッパーや各企業提供の機能をプラガブルに有効化することで機能が増える。日本版ではAmazon.co.jp: : Alexa Skills Guideに公開Skillの詳細があるけれど、まだSkill一覧を動的に確認できるページはなくて、pdfでしか公開されていないっぽい(amazon.comだとAppStoreのようなSkill一覧ページがブラウザで見られる)。

で、Skillはスマートフォンアプリと同様、個人のDeveloperが開発して公開することができるようになっている。Skillの作り方はいろいろあるけれど、最もシンプルな開発として、AWS Lambdaを直接叩くことができる。Lambdaを叩けるということは、そこを起点に各種AWSリソースが自在に使えるわけで、すでにAWSを習熟していればかなり開発難易度は低いと思う。

Alexa側のSkillの作成、受け取る発話パターンの定義などは、Alexa Skills Kit から実施する。Amazon.co.jpのアカウントがあれば誰でも使うことができる(通常はAmazon Echoのセットアップに使ったアカウントを使う)。開発イメージとしては、要はフロントエンドをAlexa Skills Kitで定義し、実際の処理を行うバックエンドをLambdaで作成する形になる。

Alexa Skills Kitで作成したSkillを、実際にAmazon Echo端末から使う方法は2種類ある。1つはいわゆる「Skillの公開」で、スマートフォンアプリのように審査を経てパブリックに公開されることになる。パブリックに公開したくない場合はもう1つ手段があって、Alexa Skills Kit上でSkillのテストを有効可すると、そのSkillを作成したユーザーのAmazon Echoと、招待メールを送った相手だけがSkillを使える。自分は手始めにかなりプライベートなものを作ったので、テスト実行で試してみている。

スクリーンショット 2017-11-18 22.51.30.png

Skillのアーキテクチャー

Alexa Skillの「フロントエンド」は、次のような概念で構成される。

  • Invocation name: そのSkillを呼び出す名前。
  • Intent: そのSkillが実行できるアクションの定義。
  • Sample utterance: 各Intentに対して定義する、それを呼び出す発話サンプルの文字列。
  • Slot: Intentが受け取る引数。

SkillはInvocation nameをトリガーとして呼び出される。例えば現時点のAlexaは運行情報のSkillをデフォルトでは持っていないので、「運行情報を調べて」と言っても反応してくれないのだけど、「JRの運行情報を調べて」と言うと、「JR」がJR東日本のInvocation nameになっていて呼び出すことができる。

たいていのSkillは複数の機能を持っているので、機能ごとにIntentを作成し、それを呼び出すための発話をSample utteranceとして定義する。Sample utteranceが "Sample" を名乗っているのは本当にサンプルだからで、一字一句ここで定義した通りの発話を必要としない。Amazon Echoがディープラーニングでそれなりに上手いこと文章を捉えてくれるらしく、Sample utteranceに近い表現であれば拾ってくれるようになっている。Sample utteranceはできるだけ多くのバリエーションを書いておくと、それだけAmazon Echoによる解釈も利きやすくなる。また、肯定の受け答え(YesIntent)や、会話のキャンセル(CancelIntent)といった、汎用的に使われると想定されるIntentについては、標準で用意されており、Sample utteranceも適当なものがすでに埋め込まれた形になっている。

SlotはSample utteranceに埋め込む引数。例えばfoodというslotを定義して、Sample utteranceに「{food} が食べたい」というようにブレースで囲んで埋め込む。Slotにどんな単語が当てはまるのかは、リストアップして定義しておく必要があるが、Built-inで用意されているSlots(日付、数字、都市の名前など)も活用できる。

ここまで書いた内容は、 Alexa Skills Kit の中で、 Skills Builder というGUIで設定ができる。サンプルを貼っておくけど、ここでは「GetBillingIntent」というIntentを作成し、「service」というslotを交える形でいくつかSample utteranceを書いている。ちなみにまだ Skills Builder はベータ版で、不具合なのかわからないが、「slotのブレース({})の前後はスペースが必要」ということに気付かずハマったりした(参考:Alexa Skill Builder | A sample utterance is invalid – DeviantDev Journal)。英語圏ではスペースがあるのは自然だけど、日本語だとつい忘れてしまうので、これはローカライズしてもらえたら嬉しいかも。

スクリーンショット 2017-11-18 23.09.01.png

Request type

まとめると、Invocation nameとSample utteranceを的確に発話したとき、それに紐付いたIntentを呼び出すということになる。しかし、Invocation nameには該当しても、Sample utteranceには適合しない場合もありえるわけで、発話はその処理結果に応じて以下に分類される。

  • LaunchRequest: invocation nameのみに適合した場合。Skillを開くだけのアクション。
  • IntentRequest: invocation name + sample utteranceに適合した場合。特定のIntentを呼び出すアクション。
  • SessionEndedRequest: エラーが発生した場合。Skillを終了、キャンセルするアクション。

Alexaは発話を受け取り、Invocation nameがトリガーされると、発話が上記3種類のリクエストのいずれに該当するかを判断し、さらにIntentRequestに該当する場合は、どのIntentを呼び出したのかも判断する。それらの情報が一定の構造(後述)にまとめられて、Lambdaへのrequestとしてスローされる。

Lambdaでの処理

Lambdaは先のAlexaによる発話処理結果を受け取り、その内容に基いて処理を行うことになる。LambdaにはAlexa用のblueprintがいくつか用意されているので、それを参考にすれば難しくはないと思う。

基本的には継続Sessionなのかという分岐、どのRequest typeが呼ばれたかによる分岐、どのIntentが呼ばれたかによる分岐が必要となる。Request typeであれば、先に書いたLaunchRequest、IntentRequest、SessionEndedRequestのそれぞれについて処理を書く必要があるし、Intentについても当然ながらすべて処理を満たさなければならない。

Request

AlexaからLambdaへ渡されるRequestのサンプルは以下。自分が作成したサンプルアプリから持ってきたものなので、詳細は気にしないでほしい。

{
  "session": {
    "new": true,
    "sessionId": "SessionId.XXXXXXXX",
    "application": {
      "applicationId": "amzn1.ask.skill.XXXXXXXX"
    },
    "attributes": {},
    "user": {
      "userId": "amzn1.ask.account.XXXXXXXX"
    }
  },
  "request": {
    "type": "IntentRequest",
    "requestId": "EdwRequestId.XXXXXXXX",
    "intent": {
      "name": "GetBillingIntent",
      "slots": {
        "service": {
          "name": "service",
          "value": "イーシーツー"
        }
      }
    },
    "locale": "ja-JP",
    "timestamp": "2017-11-18T13:24:27Z"
  },
  "context": {
    "AudioPlayer": {
      "playerActivity": "IDLE"
    },
    "System": {
      "application": {
        "applicationId": "amzn1.ask.skill.XXXXXXXX"
      },
      "user": {
        "userId": "amzn1.ask.account.XXXXXXXX"
      },
      "device": {
        "supportedInterfaces": {}
      }
    }
  },
  "version": "1.0"
}

先程書いたIntentやRequest typeの判別結果は、 request の中にdictionaryで格納されている。これを元にして処理を分岐したり、 slots を処理の引数として使ったりすることができる。

また一番最初に session というdictionaryがある通り、AlexaにはSessionの概念がある。Skillには1回の会話の往復で終わらないものがあって、例えば NAVITIME 乗換案内 は、起動→到着駅指定→出発駅指定という3回の会話で成立する検索機能を持っている。このとき、到着駅、出発駅という、会話をまたいで保持する必要がある情報を、SessionAttributesとして requestresponse に含める形で管理する。またRequest typeのうちLaunchRequestについては、Skillを起動したけどまだ具体的なIntentは呼ばれていない状態なわけで、通常はSessionを維持したまま、追加の発話を促すような実装になる。

もう一点、 applicationId という項目があるが、これはAlexaのskillごとに一意のものが割り当てられている。Lambdaは権限さえあれば他のskillから呼び出すことも可能なわけで、ある特定のskillからの呼び出しである場合にのみ反応したいのであれば、この applicationId を使って判定を行う形になる。

response

レスポンスのサンプルは以下。こちらも内容の詳細は気にせず。

{
  "version": "1.0",
  "response": {
    "outputSpeech": {
      "text": "イーシーツーの利用金額はXX.Xだらーです",
      "type": "PlainText"
    },
    "card": {
      "content": "イーシーツーの利用金額はXX.Xだらーです",
      "title": "Get Billing"
    },
    "reprompt": {
      "outputSpeech": {
        "type": "PlainText"
      }
    },
    "speechletResponse": {
      "outputSpeech": {
        "text": "イーシーツーの利用金額はXX.Xだらーです"
      },
      "card": {
        "content": "イーシーツーの利用金額はXX.Xだらーです",
        "title": "Get Billing"
      },
      "reprompt": {
        "outputSpeech": {}
      },
      "shouldEndSession": true
    }
  },
  "sessionAttributes": {}
}

responsecard という項目があるが、これはAlexaでのやり取りはすべてアプリの中にカード形式で履歴が残るので、そこに載せる情報を意味する。例えばボイスでは長文を吐けないので、詳細はカードに書くとか、画像やリンクはカードに書く、といった使われ方をしている。

reprompt はSessionが継続する場合に、次の発話を促す(到着駅はどこですか?とか)テキストを指定する部分になる。Sessionを継続させたい場合には、 shouldEndSessionfalse にし、保存したい値を sessionAttributes に代入して返す形になる。

テスト

先に書いた通り、作成したSkillは自分のアカウントだけでテスト公開が可能なので、その状態で実際にAmazon Echoからテスト実行ができる。Alexa Skills Kitの中で、文字列で発話を指定してテストすることもできるけど、実際に音声での発話がどう読み取られるか?というところが重要なので、実機で試した方がいいと思う。

当たり前だけど自動テストも何もあったものではないので、ここだけはちょっとしんどい。

まとめ

だいたいここに書いた内容があれば、取りあえず何かしらSkillを作ることはできるんじゃないかと思う。というか自分はできた。概念さえ理解してしまえば、Alexa Skills KitはGUIで簡単に使えるし、バックエンドはLambdaを書くだけなので、悩む要素は少ない。まだ発売から日が浅く、できないこともちょいちょいあるとは思うけど、だったら自分で作っちまえぐらいの精神でガンガン遊べるガジェットだと思う。

sudo時の環境変数上書き / 引き継ぎについて

sudo 実行時、環境変数は変身ユーザーのもので基本的に上書きされると認識しているのだが、実際どう上書きされているのかとか、実行ユーザー側のものを引き継いで使いたい場合はどうしたらいいのかとか、よくわかってないので少し調べたメモ。

なお、以下では sudo で他のユーザーに成り替わることを「変身」と呼称する。日本語版のMan pageでも使われている用語であるため。また確認はCentOS 7で行なっている。

デフォルトの挙動

環境変数の扱いは基本的には /etc/sudoers で管理されている。デフォルトでは漠然と「変身ユーザーの環境変数が適用される」と考えていたが、具体的には env_reset が有効になっている。

env_reset

  • これを有効にしていると、変身後に「最小限の環境」が採用される。
  • 最小限の環境とは、/etc/environment で初期化され、TERM, PATH, HOME, MAIL, SHELL, LOGNAME, USER, USERNAME, SUDO_*がセットされたもの。
  • sudo の実行ユーザーからは env_keepenv_check にマッチするものが加わる。

というわけで、必ずしもすべての環境変数が上書きされているわけではない。 env_keepenv_check は以下の通り。

env_keep

  • env_reset 有効 時に、実行ユーザーから継承される環境変数を定義する。

env_check

  • 「安全」だとみなされない場合に、実行ユーザーから継承されない環境変数を定義する。
  • 「安全」とはTZ以外のすべての環境変数について、変数値に %/ を含まないこと。

ちなみに env_reset が無効な場合に適用されるオプションもある。

env_delete

  • env_reset 無効 時に、実行ユーザーから継承されない(削除される)環境変数を定義する。

これらのオプションで定義されている変数は、 sudoers が権限的に見られない場合でも、 sudo -V で確認ができる。

env_resetの無効化

では実行ユーザーの環境変数を、変身後も引き継ぎたい場合はどうするか。 Defaults env_reset をコメントアウトするなりして完全に無効化することもできなくはないが、それはセキュリティ的によろしくないので以下2パターンかと。

  • 上述の env_keep を使う。
  • sudo -E を使う。

env_keep を使う場合は全ユーザー共通の設定となってしまう。一方で sudo -E は、 -E オプションを指定した場合のみ env_reset が無効化されるため、選択権がある。というわけで後者が良さそう。

setenv

しかしながらデフォルトだと -E は使えない。使えるようにするには setenv オプションを有効にする必要がある。

ただし、これについても全ユーザーに対して有効化することは、Man pageで明確に非推奨とされている。以下のようにタグで設定するのがベター。

%hoge        ALL=(ALL)       SETENV: /usr/bin/fuga

$HOMEについて

sudo -E を使っても、 $HOME は実行ユーザーのものを維持できない場合がある。

これは always_set_home オプションがデフォルト有効になっているためで、これは常にsudoの -H オプションが使用された状態になる(つまり変身ユーザーの $HOME が使われる)。 $HOME 含めて実行ユーザーの環境を使いたい場合は、これを無効にしておく必要がある。

似たようなオプションに set_logname なんかもあったりする。

結論

  • Defaults env_reset はそのまま有効にしておく。
  • 必要な場合に限って SETENV タグを使い、 -E オプションを使えるようにする。
  • $HOME の上書きが必要な場合は Defaults always_set_home も外す。

GrafanaのDashboard等をファイルで管理する

Grafanaのdatasourceとdashboardは従来REST APIか、GUIを通じてしかexport/importできなくて、Git等で管理するのに少し工夫が必要だったのだけど、v5.0からファイルでの管理に対応した。それについてドキュメント読んでメモしただけの記事。

Provisioning | Grafana Documentation

ファイルで管理できると、AnsibleやDockerとの相性も良くなってとても楽。

Datasources

/etc/grafana/provisioning/datasources/hoge.yml に設定を以下のように書く。datasourceが複数ある場合、ファイルは分割しても良いし、 datasources にmapで複数指定してもよい。

apiVersion: 1

datasources:
- name: timeseriesData
  type: influxdb
  access: proxy
  orgId: 1
  url: http://192.0.1.1:8086
  user: user
  password: password
  database: timeseries
  isDefault: true
  editable: false
  version: 1

設定を更新したときに version をincrementしておけば、古いversionで新しいversionは上書きできなくなっているので安心、みたいなこともドキュメントには書かれているのだが、どうも上手く動かなくて使い方がわからない。

editable がデフォルトで false になっており、yamlでprovisioningした設定は、GUIからは書き換えできなくなっている。これはyamlからその設定を削除した後も有効。yamlで追加したdatasourceを消すには、 editable: true にしてGUIから消すか、 delete_datasources に設定するかの2択。

一意性

name で判断されている。既存のdatasourceと同じ name を持つ設定をyamlで準備した場合、yamlの値が優先されて、上書きされてしまうため注意が必要。

Dashboards

こちらは2箇所に設定が必要。まず /etc/grafana/provisioning/dashboards/hoge.yml を置く。

apiVersion: 1

providers:
- name: 'hoge'
  orgId: 1
  folder: ''
  type: file
  disableDeletion: true
  editable: true
  options:
    path: /var/lib/grafana/dashboards

ここで書いた name はGUIだと特にどこでも使われていないと思うので、なんでもいい。これは単に、実際のdashboardのjsonを providers[0].options.path に置きますよという宣言をしているだけ。あとは diableDeletioneditable はお好みに応じて。ドキュメントに各設定値の詳細が書かれていないので、今のところ挙動がわかりにくいが、disableDeletion: true にしておくと、jsonを削除してもdashboardは削除されなくなる。

実際のjsonは、この場合では /var/lib/grafana/dashboards に1つずつ配置する。このjsonは、従前のバージョンからGUIで出力できていた、あの書式で良い。あまりGrafana dashboardをjsonでイチから書く人もいないと思うので、一旦GUIで作ってからexportすればOK。但し、exportした際には付与される id という値が、ファイルでのprovisioningの場合は不要となっているので、削っておく必要がある。

一意性

uid で管理される。uidはGUIから作成したdashboardだとランダムに採番されていて、yamlから作成するときはjsonの中で好きに採番できる(採番しなくてもOKで、その場合はやはりランダムになる)。すでに存在するuidと同じuidでjson設定を書いた場合、そのuidのdashboardが強制的に更新される。uidはdashboardのURLに含まれるため、複数のGrafanaを立てて、同じグラフを双方で表示するとき、一意なURL採番をするのに役立つ。

ただし、注意するべきは title も一意である必要があるということ。例えば1つのGrafanaサーバーに、 title が同一で、 uid は異なるdashboard設定を複数用意しても、後発の設定は反映されない。GUIですでに存在するdashboardの設定をjsonファイルで配置し、別uidを振ったとしても、既存の方のdashboardを一度削除しないことには、jsonの方は反映されない。

Terraformドキュメントやsnippetを出力するCLIツール、tfdocをGoで作った

Terraformを書くとき、各リソースをどう書けばいいのか、常にウェブで Terraform Documentation を見ながら書いていたんだけど、ブラウザとエディタを行き来するのが結構面倒だった。Ansibleにはドキュメントとスニペットを吐き出す
ansible-doc というCLIツールがあるので、これと同じものがTerraformにもあったら、コマンドでドキュメント確認もできて便利だと考えて作ってみた。

chroju/tfdoc

※むしろこの手の入力補助ツールないとすげーTerraform辛くない??と思うんだけど、今のところあんまり良いのが存在しないので、みなさまどうやって普段tfファイル書いているのか気になるところ。

動作

完全に ansible-doc をリスペクトして作っている。

使い方は引数に目当てのリソース名を与えて実行するだけ。ざっくりこういう感じで出力される。

$ tfdoc aws_instance | head -n 20
aws_instance
Provides an EC2 instance resource. This allows instances to be created, updated,and deleted. Instances also support provisioning.

Argument Reference (= is mandatory):


= ami
  (Required) The AMI to use for the instance.

- availability_zone
  (Optional) The AZ to start the instance in.

- placement_group
  (Optional) The Placement Group to start the instance in.

- tenancy
  (Optional) The tenancy of the instance (if the instance is running in a VPC).
  An instance with a tenancy of dedicated runs on single-tenant hardware.
  The host tenancy is not supported for the import-instance command.

スニペット形式で出力したいときは、 -s オプションを付ける。リダイレクトでファイルに書き込んでからエディタで開いて作成を始めるととても楽。

$ tfdoc -s aws_subnet
resource "aws_subnet" "sample" {
  // (Optional) The AZ for the subnet.
  availability_zone = ""

  // (Required) The CIDR block for the subnet.
  cidr_block = ""

  // (Optional) The IPv6 network range for the subnet,in CIDR notation. The subnet size must use a /64 prefix length.
  ipv6_cidr_block = ""

  // (Optional) Specify true to indicatethat instances launched into the subnet should be assigneda public IP address. Default is false.
  map_public_ip_on_launch = ""

  // (Optional) Specify true to indicatethat network interfaces created in the specified subnet should beassigned an IPv6 address. Default is false
  assign_ipv6_address_on_creation = ""

  // (Required) The VPC ID.
  vpc_id = ""

  // (Optional) A mapping of tags to assign to the resource.
  tags = ""

}

他にも、ドキュメントを実際に見たいよねってときもあるのでドキュメントURLを吐き出す --url というオプションを付けたり、スニペットのフォーマットを少しずつ調整するためのオプションも用意したりしている。詳細はREADMEを参照で。

実装

最近はCLIツール作成にはGoがよく使われている傾向も見かけるので、習得も兼ねてGoを使って実装した。

Terraformドキュメントを提供しているのが、ウェブのHTMLによるドキュメントしかないようだったので、そこから goquery でせこせことスクレイピングして出力している。そのためインターネットに繋がらない環境だと動作しないんだけど、まぁTerraformをインターネット断絶環境で使う物好きな方もそんなにいないだろうと。

予めスクレイピングした結果を、tfdocのリポジトリ内に含めてしまって、ローカルから結果を吐くことも出来るとは思うけど、それをやると最新のドキュメントを追跡するのが結構大変そうだったし(自動でやらせればいいんだけど)、現状はリアルタイムにスクレイピングさせている。

現状のソースを抜き出すとこんな感じ。案外Terraformドキュメントの書式やHTMLの構造が一定ではなくて、スクレイピングってまぁ便利だけど悪手なのは確かだなと思った次第。現状全リソースでテストは出来ていないので、抜け漏れはあるのかもしれない。

func scrapeTfResource(name string, res *http.Response) (*TfResource, error) {
    var ret = TfResource{Name: name}

    // Load the HTML document
    doc, err := goquery.NewDocumentFromReader(res.Body)
    if err != nil {
        err = fmt.Errorf("HTML Read error: %s", err)
        return nil, err
    }

    ret.Description = strings.Replace(strings.TrimSpace(doc.Find("#inner > p").First().Text()), "\n", "", -1)
    doc.Find("#inner > ul").Each(func(i int, selection *goquery.Selection) {
        if i == 0 {
            selection.Children().Each(func(_ int, li *goquery.Selection) {
                arg := scrapingResourceList(li)
                ret.Args = append(ret.Args, arg)
            })
        } else {
            fieldName := selection.Prev().Find("code,strong").Text()
            for i, arg := range ret.Args {
                if arg.Name == fieldName {
                    selection.Children().Each(func(_ int, li *goquery.Selection) {
                        ret.Args[i].NestedField = append(ret.Args[i].NestedField, scrapingResourceList(li))
                    })
                }
            }
        }
    })

    return &ret, nil
}

func scrapingResourceList(li *goquery.Selection) *tfResourceArg {
    a := &tfResourceArg{}
    a.Name = li.Find("a > code").Text()
    a.Description = strings.TrimSpace(strings.SplitN(li.Text(), "-", 2)[1])
    a.Description = strings.Replace(a.Description, "\n", "", -1)
    if strings.Contains(strings.SplitN(li.Text(), " ", 3)[2], "Required") {
        a.Required = true
    } else {
        a.Required = false
    }
    return a
}

特にリソースでネストになっている部分(例えばAWS: aws_instance - Terraform by HashiCorp)の処理が面倒であった。

またコマンドラインオプションを実装するにあたり、ショートオプションとロングオプションを両方使いたい、ロングオプションはGNUスタイル(Go製CLIツールでよくある「-hoge」というハイフン1つスタイルではなく、「--hoge」のスタイル)にしたいという気持ちがあったので、それらを満たす pflag を使っている。

Goについて

Goでまとまったライブラリを作ったのは、これが初めてなんだけど、バイナリでCLIツールを配布できる、クロスコンパイルが容易にできるというのはやっぱり楽。

またテストのための機能が内包されていたり、go lintgo fmt のようなコーディングを補助してくれる機能が豊富で、プログラミングを学ぶにあたってもとても良い言語だなと感じた。

このツールを育てながら、もっとGoのプラクティスを学んでいきたい。

今後について

  • 出力に色を使ったり、太字を使ったりしてもっと見やすくしたい。
  • スニペットのフォーマットを terraform fmt による整形後のような形にしたい。
  • テストカバレッジ上げたい。
  • 何か他に良い機能があったら追加していきたいが、シンプルさを忘れないようにはしたい。

Evernote がエクスポートする xml ファイルをプレーンテキストに変換する

表題の雑スクリプトを書いたので誰か欲しい人いるかなという感じで書いておきます。

背景

  • Evernote はノートのエクスポート形式が HTML か、独自の XML フォーマットだけであり、プレーンテキストは選べません。
  • また複数ノートを一括でエクスポートした場合、全ノートが1つの XML に結合されてエクスポートされます。
  • 非常に扱いづらいのでプレーンテキストに変換することにしました。

一応先人の知恵もあるにはありました。以下は PHP での実装です。

panicsteve/enex-dump: PHP script that accepts an Evernote export (ENEX) file and produces a folder of plain text documents.

しかし XML ファイルをまるごとメモリに載せてから処理する形式になっているため、 1000 ノート単位で一括エクスポートして XML が数百MBに達したりしていた自分のユースケースではまともに動かず、自分で作ることにしました。

なおプレーンテキスト変換、ですので、元のノートも文字列データを対象としています。 Evernote は画像や pdf や音声ファイルも収容できますが、それらは考慮外です。

XML のフォーマット

パースするにあたり、 Evernote が出力する XML の構造を簡単に書いておきます。だいたい以下のようになっています。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export3.dtd">
<en-export export-date="20181007T043300Z" application="Evernote" version="Evernote Mac 7.5.2 (457172)">
<note><title>hoge</title><content><![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd">
<en-note>
noteの内容
</en-note>
]]></content><created>20150729T142057Z</created><updated>20150729T143036Z</updated><note-attributes><author>example@example.com</author><source>desktop.mac</source><reminder-order>0</reminder-order></note-attributes></note>
<note><title>fuga</title><content><![CDATA[<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd">
<en-note>
noteの内容
</en-note>
]]></content><created>20150720T065805Z</created><updated>20150720T070625Z</updated><note-attributes><author> example@example.com</author><source>desktop.mac</source><reminder-order>0</reminder-order></note-attributes></note>
</en-export>
  • 全体は <en-export> タグで囲われている。
  • その中に <note> タグで各ノート情報が入っている。
  • ノート情報の中で、例えば表題は <title> 、ノートの内容は <en-note> といった各タグで囲われている。

基本的に各ノートの情報は

  • <note> 開始タグとノートのタイトル情報で1行
  • DOCTYPE で1行
  • <en-note> の開始タグで1行
  • 内容
  • </en-note> 終了タグで1行
  • </note> 終了タグとファイルメタ情報で1行

と行単位で結構綺麗に分かれていて、これがエクスポートしたノート数分繰り返される形になります。ただし <en-note> 開始終了タグとノートの内容がすべて1行に書かれている場合が例外的にあります(おそらく内容が短いノートの場合?)。

また Evernote のデータは基本的にリッチテキストですので、装飾については HTML タグで付加された状態になっています。

実装

Gist に貼っておきました。

XML をパースしてもよかったのですが、先に貼ったようにだいたい1行ずつ要素が分かれているので、行単位で読み込んでパースして処理してノート1つ分読み込むたびに吐き出して、の方が処理も速そうだったのでそうしました。実装はだいぶ雑ですので動かない場合もあるかもしれません。あと macOS でしか実行してないです。

convert_evernote_xml.go

引数に XML ファイルのパスを与えて実行すると、以下のように動作します。

  • カレントディレクトリに output フォルダが無ければ作成する。
  • 1ノート1ファイルで ouput フォルダ内にエクスポートする。
  • 装飾の HTML タグはすべて破棄する。
  • ファイルの atime と mtime はノートの最終変更時刻にする。

AWS マルチアカウントの管理を Toil にしないために

この記事は GLOBIS Advent Calendar 2019 - Qiitaの9日目です。

7月に GLOBIS へ SRE として参画してから、集中的に取り組んできたことの1つである「AWSマルチアカウントの構成や運用効率化」に関して書いていきます。

AWS マルチアカウント構成について

GLOBIS では AWS アカウントを環境ごとに分ける構成を取っています。各サービスにつき開発、ステージング、本番環境という形で 3 アカウントを設けており、現在その総数は 20 に届こうかというところです。私が参画し始めた後にもアカウントは増えています。

マルチアカウント構成はポピュラーな AWS アカウントの使い方だとは思いますが、 GLOBIS の場合以下のメリットを感じています。

  • GLOBIS はサービスごとに開発チームが分かれており、各チームへ的確に分割された AWS IAM 権限を付与しやすい。
  • AWS リソースを操作する際に、誤ったサービス / 環境を操作してしまう可能性が低い。
  • 1アカウント内のリソースはすべて1つのサービスを構成する要素となっているため、インフラ構成が見通しやすい。

しかし一方で、サービスの増加に比例して管理対象のアカウントが増えてしまうのが SRE には辛いところです。いわゆる SRE bookにも、 Toil の定義として「サービスの成長に対して $O(n)$ であること」が書かれており、アカウントの増加に比例して管理作業が増えるような事態は避けていく必要があります。そこで7月からの活動の中では、以下のような効率化作業を実施しました。

  • Switch role の導入
  • ChatOps の促進
  • Infrastructure as Code の促進

順に見ていきます。

Switch role の導入

様々な AWS アカウントを日々行き来しながら作業する SRE については、すべて Switch Role (sts:AssumeRole) を使った IAM に移行しました。

通常ですと、 AWS マネジメントコンソールからは同時に1つの AWS アカウントにしかログインできませんので、複数のアカウントで並行作業を行いたいときは、複数のブラウザを使うなどして、セッションを分ける必要があります。 Switch Role を活用すると、 1つの IAM User から、別の AWS アカウントの IAM Role へコンソール上で Switch ができるようになり、アカウントごとにログインや認証操作を行わずとも、スムーズにアカウント間を行き来できるようになります。

Terraform による設定作業自動化

Switch Role を「使う」こと自体は非常に便利なのですが、そのための設定作業については、 Switch 元のアカウントに IAM User を作成し、さらに Switch 先の各アカウントに IAM Role を設ける必要があり、煩雑になりがちです。そこで Switch Role の作成や削除は Terraform で管理しています。

Terraform の実行は CodePipeline + CodeBuild を用いて自動化しました。 GitHub で Pull Request を出すと terraform planが実行されて、 mercari/tfnotifyにより結果が Pull Request に貼り付けられます。 Pull Request を master ブランチへ merge されると terraform applyが実行される仕組みです。

Image from Gyazo

ポイントとしては、この Terraform の実行にもマルチアカウントな権限を必要とする点です。そこで CodeBuild の IAM Role には以下のような権限を与えています。

data"aws_iam_policy_document""codebuild_iam_terraform_policy"{statement{actions=["sts:AssumeRole"]resources=["arn:aws:iam::*:role/codebuild_iam_terraform",]}}

この権限の意味するところは、任意の AWS アカウントの codebuild_iam_terraformという role へ assumeRoleが可能となる、ということです。そのため新しい AWS アカウントが追加された際には、そのアカウントに IAM Role codebuild_iam_terraformを作成するという最小限の操作だけで、 Terraform の動作対象に含めることができます。

開発 / ステージングと本番は混在させない

Switch Role を設定する際の原則として、開発 / ステージングから本番へ、本番から開発 / ステージングへは Switch をさせないようにしています。これはひとえに誤操作の防止という観点です。

サードパーティ OSS の活用

また Switch Role の利用にあたっては、以下のようなサードパーティツールを活用することで、さらに利便性を高めることができています。

ChatOps の促進

AWS アカウントの切り替えが簡単になったとは言え、簡単かつ頻発する作業であればログインすら行わずに済ませたいところです。そこで ChatOps を活用しています。

例えば弊社ではデプロイに CodePipeline を用いているのですが、本番環境のみ事故を避けるために手動でパイプラインを起動する必要があり、またさらに承認フェーズを挟むようにしています。これを Slack 上だけで完結できるようにしました。

Image from Gyazo

具体的にはこのような構成です。 slash command でパイプラインを起動し、承認フェーズに至ると SNS と Lambda を経由して、以下のように Interactive Message Button が Slack に表示されます。

Image from Gyazo

ボタンをクリックすると、誰がクリックしたのかが明示的に Slack へ残されます。

Image from Gyazo

図中にも書いてある通り、この構成は Serverless Framework で記述しており、新しい AWS アカウントが追加されたときにも、すぐに展開が可能になっています。

この他にも、各メンバーが必要に応じて Lambda を書くなどして、 GuardDuty の検知やセキュリティ上重要な操作の実行など、様々な Notification を Slack へ送っています。

Infrastructure as Code の促進

GLOBIS では積極的なインフラのコード化を是としており、従来から Ansible や Codenize.toolsを利用しているほか、独自の Python スクリプトなども用いています。最近ではこれに加えて Terraform や Serverless Framework も活用が進んでいます。

Ansible は別として、 Terraform 、 Serverless Framework 、 Codenize.tools はいずれも AWS のコード化に使うツールで担当範囲が重なるようにも思えますが、主に以下のように適材適所で使い分けています。

  • Codenize.tools : Route53 (roadworker)
  • Serverless Framework : AWS Lambda 関連
  • Terraform : それ以外

Codenize.tools

Route53 についてはレコード数が増えてくると terraform planがかなり遅くなります。 --parallelismによる並列数の増加なども試みましたが、今のところ改善策が見つからないため、 Terraform ではなく roadworker を使う形としています。記述も Terraform に比べてかなりシンプルに書くことができるのが魅力です。

Serverless Framework

GLOBIS では先の slack 通知に使う Lambda のように、全アカウント横断で用いることができる汎用的な Lambda が少なくありません。そのため Lambda のコードを更新すると、全アカウントに対してデプロイ作業を行わなくてはなりません。

さすがにいちいち zip で固めてアップロードして、、、とはやっていられず、デプロイをワンコマンドで済ませるために Serverless Framework (sls) の導入を進めました。実行に必要となる IAM Role なども含めて YAML で簡潔に定義できる点、 zip にまとめてデプロイする処理がワンコマンドで済む点などを鑑みると、 Lambda については Terraform より sls でコード化したほうが取り回しがしやすいと感じています。

ただ、すでにデプロイ済みの Lambda を後から sls でコード化する、 import 操作を行うのは困難です。これは sls が裏側では Cloud Formation (CFn) に紐ついているため、単にコード化するだけではなく、 CFn の stack も作らなくてはならないあたりが関係しています(まぁ一旦 Lambda を削除して、 sls から改めてアップロードすればいい話だったりもしますが)。

そのため sls 導入以前よりデプロイ済みの Lambda については、代替として Makefile によるデプロイコマンドの統一を進めつつあります。

FUNCTION_NAME:= dummy
PROFILE:= dummy

lambda_function.zip:ziplambda_function.ziplambda_function.py.PHONY:update-codeupdate-code:lambda_function.zipaws lambda update-function-code --function-name $(FUNCTION_NAME) --zip-file fileb://lambda_function.zip --publish --profile $(PROFILE).PHONY:cleanclean:rm-flambda_function.zip.PHONY:deploydeploy:update-code clean

なお、最終的には sls や Makefile のコマンド実行自体を CodePipeline などで自動化するつもりです。

Terraform

Terraform は部分的に導入していますが、まだ活用方法を模索中という段階です。すべてをコード化することは時間的にも難しいことがあり、再利用性の高い構成を module 化することで、効果的に導入していければと考えています。

例えば SPA などは、 CloudFront + S3 + Lambda@Edge というほぼ決め打ちの構成が存在しているので、 module 化を進めやすい部分です。また各 AWS アカウントで統一的な構成を取っている箇所もコード化を進めるべき部分です。 GLOBIS の場合、サブネット構成が DMZ は第3オクテットが1、 Trust は2、という具合に統一したルールを設けているほか、 S3 バケットもログ用のバスケット、 CodeBuild の artifact を置くためのバケットなど、標準的に必要となるものがいくつか定まっています。このように標準化されている部分をコード化することから進めるのが妥当と考えています。

今後取り組みたいこと

最後に、今後取り組んでいきたいことをいくつかまとめて、この記事の〆とします。

Slack からの AWS CLI 実行

先日発表されたばかりの新機能です。 AWS Chatbot を使って、 Slack 上から AWS CLI の実行ができます。

Running AWS commands from Slack using AWS Chatbot | AWS DevOps Blog

さすがにリソース変更を伴う操作を Slack Channel のメンバー全員に許可してしまうのは怖いですが、稼働中 instance の ID や IP を調べるなど、 read 系の操作を可能にするだけでも利便性はかなり高まるのではないかと思います。

Organizations の活用

これは個人的な感想なのですが、正直 AWS Organizations はローンチ当初そこまで出来ることが多いわけでもなく、それほど魅力は感じていませんでした。

しかし、最近は Control Tower や Saving Plans など、 Organizations が有効化されていることを前提として、マルチアカウントにガバナンスをもたらしてくれるサービスが増えてきています(Saving Plansで「推奨事項」を表示するのに、 Organizations が必要です)。おそらく今後もこの流れが続き、マルチアカウントを統制する上で Organizations が必須になりそうな気がしています。そのためそろそろ Organizations 導入も考えるべきではないかという声は、チーム内で何度か挙がっています。

構成自体の見直し

GLOBIS のエンジニア組織が出来て3年近くが経ち、インフラ構成自体も大きく見直して良い時期になりつつあります。

例えば EC2 へ ssh 接続するためのいわゆる「踏み台サーバー」を現在は AWS アカウントごとに設けていますが、これを統合したり、 SSM Session Manager に代替することで、踏み台の管理コストが抑制が図れるのではないかと考えています。またコンテナの導入も検討を始めていますが、仮に EKS を導入するとなると、 k8s の RBAC と IAM をどのように絡めて権限制御を行うかを考えなくてはならなくなったり、コンテナエコシステムを前提とした AWS 構成が求められるようになります。今後どのようにサービスが拡充されていくのか長期的に考えつつ、スケールしやすいインフラを改めて考えていきたいところです。

このような Toil を抑える仕組みを常に考えていくことによって、 GLOBIS のサービス拡大を今後も SRE の立場から下支えできればと考えています。

Browsing Latest Articles All 21 Live