動画を探して自動ツイートしてくれるPython製botをAWSに載せてみた(前編)

TL;DR

  • YouTubeから動画を拾ってTweetするbotをPythonで開発し、AWS Lambdaに載せてみました
  • 全2記事です。前編のこちらでは、主にPythonでの開発周りのトピックにフォーカスします
    • TwitterAPIを使ってプログラムからツイートしてみます
    • YouTubeのページを構文解析し、文字列操作を使って動画URLを抽出してみます

動機

新しい職場にて初めてAWSを触ることになったので、これを機にと個人アカウントを取ってみました。チュートリアルだけというのももったいないので、何か自分のためのサービスを作って載せると面白そうです。

で、Twitterのbot開発にはもともと興味があったので、これも前から興味を持ちつつ触ってなかったPythonでbotを作り、lambdaを使って運用してみようと思い立ちました。AWS lambdaは2017年4月からPython3系を扱えるようになったので、心置き無く最新バージョンで書けそうだなー、というのも狙いです。

ユーザーストーリー

毎日の退勤をもう少し楽しみにするために、定時になると自分が興味ありそうなYouTube動画をbotが勝手に検索して、自分のTwitterアカウントに届けてくれるようにしたい。
スクリーンショット 2017-12-06 23.30.33.png

前提

  • 開発にはMacを使用します
  • Pythonは3.6系を使用します
  • pyenvもvirtualenvも使用しません。議論はあろうかと思いますが、個人開発なので。。
  • で、開発環境構築はこちらの記事等を参照しました
  • bot化したいTwitterアカウントはあらかじめ用意してあるものとします

TwitterAPIを使ってプログラムに呟かせる

アクセスキーの取得

bot化したいアカウントでTwitter Application Managementにログインすると、アプリケーションの作成とConsumer Key、及びAccess Tokenの取得ができます。

なお、Appの作成にはTwitterアカウントが電話番号認証済みである必要があります。認証済みでないと怒られるので、エラーメッセージ中のリンクからさらっと済ませておきましょう。

  • Consumer Key
  • Consumer Key Secret
  • Access Token
  • Access Token Secret

以上の4パラメータがあればプログラムからのツイートができます。コピーしてこんな感じのファイルを作っておきましょう。

config.py
CONSUMER_KEY        = "xxxxxxxxxxxxxxxxx"
CONSUMER_SECRET     = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
ACCESS_TOKEN        = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
ACCESS_TOKEN_SECRET = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"

複数の外部ユーザーからアクセスがあるようなアプリケーションの場合(=「このアプリケーションとの連携を許可しますか?」など出るやつ)はそれぞれの役割についてもう少し説明が必要ですが、今回はある程度一緒くたに考えてしまっても実装に支障ありません。

PythonでOAuth認証

ライブラリの導入と管理

Pythonのライブラリは、パッケージ管理ツールであるpipでインストールできます。仮想環境がない場合、オプション無しで勝手にglobalに入るのがうーん、という感じですがまあそれは置いておいて。

PythonでHttp通信を行うライブラリとしては、requestsがポピュラーなようです。また、今回はTwitterAPIを使うための認証が必要なので、OAuth認証を扱えるライブラリも必須です。ここはrequestsと同じところが公開しているrequests_oauthlibを使用しました。

pip3 install requests requests_oauthlib

さて、インストールはできましたが、今度は開発するプロジェクトがこれらのライブラリに依存していることを表明しておくのがマナーです。js界隈で言うところのpackage.jsonですね。

Pythonでは依存関係を記したrequirements.txtなどを作っておくケースが多いようです。

requirements.txt
requests==2.18.4
requests-oauthlib==0.8.0

ちなみに、pip3 freeze > requirements.txtでインストールされた依存関係をrequirements.txtに吐き出せます。

逆に.txtファイルを元に一括インストールする場合は、-rオプションを用いてpip3 install -r requirements.txtなどと書けます。結構便利です。

つぶやいてみる

first_tweet.py
from requests_oauthlib import OAuth1Session
import config, json

twAuth = OAuth1Session(
  config.CONSUMER_KEY,
  config.CONSUMER_SECRET,
  config.ACCESS_TOKEN,
  config.ACCESS_TOKEN_SECRET)
apiURL = "https://api.twitter.com/1.1/statuses/update.json"
params = { "status": "プログラムにツイートさせてみるテスト" }

res = twAuth.post(apiURL, params = params)
print(json.loads(res.text))

先ほど作ったconfig.pyimportして、これだけ。思ったよりだいぶ手軽です。Twitterにアクセスして実際にツイートされたことを確認しましょう!

また、せっかくなのでレスポンスをjsonライブラリでロードして吐き出してみます。

{'created_at': 'Wed Dec 06 14:00:00 +0000 2017', 'id': 9384076800000000, 'id_str': '9384076800000000', 'text': 'プログラム
にツイートさせてみるテスト', 'truncated': False, 

...(中略)...

'retweeted': False, 'lang': 'ja'}

思ったよりいろんな属性があることがわかりますね。深掘りは公式のリファレンスにて。

YouTubeから動画のURLを拾ってくる

続いて、YouTubeから動画を探してくるパートです。

Webクローリング

この分野では、「クローリング」や「スクレイピング」と言った言葉が有名です。

クローリングとスクレイピング

クローリングはウェブサイトからHTMLや任意の情報を取得する技術・行為で、 スクレイピングは取得したHTMLから任意の情報を抽出する技術・行為のことです。

たとえば、あるブログの特徴を分析したい場合を考えてみましょう。
この場合、作業の流れは

  1. そのブログサイトをクローリングする。
  2. クローリングしたHTMLからタイトルや記事の本文をスクレイピングする。
  3. スクレイピングしたタイトルや記事の本文をテキスト解析する。

というようになります。

今回は、YouTubeをクローリングし、その中から動画のURLをスクレイピングすることになりますね。

Webページのクローリングとスクレイピングを行う際は、それがどんな目的のものであれ、HTMLを構文解析することが必須となります。Pythonでは、これを強力に支援するBeautifulSoupと言うライブラリがあります。執筆時点で最新のbeautifulsoup4を導入してみます。

pip3 install beautifulsoup4

早速使ってみましょう。Qiitaのトップページから<a>タグを探し、その中に含まれるhref属性の値を取得してみます。

crawling.py
import requests
from bs4 import BeautifulSoup

URL = "https://qiita.com/"
resp = requests.get(URL)

soup = BeautifulSoup(resp.text)

# aタグの取得
a_tags = soup.find_all("a", href=True)
for a in a_tags:
    print(a["href"])

結果

/about
https://qiita.com/sessions/forgot_password
https://oauth.qiita.com/auth/github?callback_action=login_or_signup
https://oauth.qiita.com/auth/twitter?callback_action=login_or_signup

・・・(中略)

https://qiita.com/api/v2/docs
https://teams.qiita.com/
http://kobito.qiita.com

いい感じです!

HTMLパーサーについて

さて、先のコードを実際に試すと、HTMLパーサーが明示されていないために警告が出ます。これは実際の解析時に使われるパーサーが実行時の環境に依存するためです。異なる環境下で同じ振る舞いを期待するには、使用するHTMLパーサーを明示してあげる必要があります。

デフォルトではhtml.parserが使われますが、lxmlかhtml5libを導入してこちらを明示してあげるのが無難なようです。このあたりの情報は下記の記事をだいぶ参考にさせていただきました。パーサーの選択だけでなくスクレイピング全般の情報が非常によくまとまっているエントリなので、オススメです。

PythonでWebスクレイピングする時の知見をまとめておく – Stimulator

パーサの良し悪しを考えるとlxmlでチャレンジしてダメならhtml5libを試すのが良さそう。

今回はこの1文に愚直に従ってみます。事前にpip3 install lxml html5libも忘れずに。


import requests
from bs4 import BeautifulSoup

URL = "https://qiita.com/"
resp = requests.get(URL)

+try:
+  soup = BeautifulSoup(resp.text, "lxml")
+except:
+  soup = BeautifulSoup(resp.text, "html5lib")
-soup = BeautifulSoup(resp.text)

# ...以下は先ほどと同様

Crawlerクラスを作ってみる

すでにPythonでオブジェクト指向な書き方を経験している方はこの辺りを飛ばしていただいて構いません。せっかくHTMLを解析してくれるコードができたので、クラスとして書き換えてみます。

crawler.py
import requests
from bs4 import BeautifulSoup

class Crawler:
    def hrefs_from(self, URL):
        a_tags = self.soup_from(URL).find_all("a", href=True)
        return set(map(lambda a:a["href"], a_tags))

    def soup_from(self, URL):
        res_text = requests.get(URL).text
        try:
            return BeautifulSoup(res_text, "lxml")
        except:
            return BeautifulSoup(res_text, "html5lib")

個人的にはインスタンスメソッドの第1引数が常にselfでなければならないのは書く量が増えるので少しもどかしいですね。ハマりポイントにもなりかねない…。

ちなみに、ここではラムダ式を使用し、hrefs_fromメソッドの戻り値の型をsetにしてみました。これは、今回のユースケースを鑑みてリンク先URLの重複を排除した方が便利と判断したためです。出現頻度など解析したい場合はまた改めて設計を考える必要があるでしょう。

継承と、YouTubeへのアクセス

YouTubeをクローリングするにあたって、「検索文字列を与えたら検索結果のページをクローリングし、動画を探してくる」などの機能があると便利そうです。先ほどのクラスを継承して、実装してみます。

tube_crawler.py
import random
import re
from crawler import Crawler

class TubeCrawler(Crawler):

    URLBase = "https://www.youtube.com"

    def hrefs_from_query(self, key_phrase):
        """
        検索文字列を与えると検索結果ページに含まれるhref属性の値を全て返す
        """
        return super().hrefs_from(self.URLBase + 
            "/results?search_query=" + key_phrase.replace(" ", "+"))



    def movies_from_query(self, key_phrase, max_count = 10):
        """
        検索文字列を与えると検索結果ページに含まれる動画のビデオIDを返す
        """
        return self.__select_movies(self.hrefs_from_query(key_phrase), max_count)



    def __select_movies(self, hrefs, max_count):
        """
        privateメソッド。href属性の値のsetからビデオIDのみを返す
        """
        filtered = [ re.sub( "^.*/watch?v=", "", re.sub( "&(list|index)=.*$", "", href )) 
            for href in hrefs if "/watch?v=" in href ]
        return filtered[:min(max_count, len(filtered))]



    def choose(self, movie_ids, prefix = "https://youtu.be/"):
        """
        渡した文字列のリスト(ビデオIDのリストを想定)から1つを選び、prefixをつけて返す
        """
        return prefix + random.choice(movie_ids)

文法的には継承とprivateメソッドの書き方あたりが新しい話題となります。この記事の主題ではないので特段の説明は省きます。

実際に試すとわかるのですが、検索結果のページにノイズとなるリンクが多いばかりか、再生リストへのリンクなど紛らわしいものも多く、その辺を適切に弾いていくのに手こずりました。おかげでfilter関数や正規表現に少し強くなれた気がします。

正規表現についてはこちらの記事をだいぶ参考にしました。

Pythonの正規表現の基本的な使い方

繋げてみる

準備が整ったので検索->ツイートの流れを試してみます。

main.py
#!/usr/bin/env python3
# -*- coding:utf-8 -*-

from tube_crawler import TubeCrawler
from tweeter import Tweeter
import config

def main():
    t = TubeCrawler()
    movies = t.movies_from_query("Hybrid Rudiments")
    chosen = t.choose(movies)

    # ツイートする部分をクラス化したもの
    tw = Tweeter()
    tw.reply(config.REPLY_TO, chosen)

if __name__ == '__main__':
    main()

エントリーポイントとなる関数が必要かなー、と思ったので何気なく(そう、本当に何気なく。これで良いと思っていたんですLambdaを使うまでは…)main関数を作成。

直接./main.pyでも呼べるようにこの辺からShebangを記述し始めました。また、末尾はファイル名で直接実行した場合にmain()を呼ぶためのおまじない。Rubyにも似たやつがありますね。あとはターミナルから呼んで動作確認するだけです。

$ ./main.py

実行したところ問題なく動きそうだったので、次回はAWS Lambdaに載せていきます。それなりの尺となったのでこのページはここまでです。お読みいただきありがとうございました。

リンク

続きを読む

G Suiteを利用してGAMでユーザーごとの利用できるAWSアカウントとロールを管理する

NIFTY Advent Calendar 2017 11日目の記事になります。

AWSのアカウント管理や認証をどうしていけばいいのか試行錯誤してました。
タイトルから個人的な結論が出ていますが、考えた順に書いていきます。

Microsoft ADで管理してMasterアカウントにログイン後、SubアカウントにSwitch Roleする

AWS_DirectoryService.png

IDaaSやADを自前で持っていない場合は、すべてがAWSで完結するからこれが綺麗だと思う。

AWS公式の提案手法

マルチアカウントにする意義と、そのためのアカウント間の構成を教えてくれるので、読んだことがない方は一度こちらを読んでおくことをオススメします。

上記の構成を実現するためのCloud Formationのサンプルなども提供されている。

OpenAMをIdpとしてAWSにSAML認証でログインする

OpenAM.png

すでにLDAPを持っていて、できるだけ内部で管理したい場合の構成。

OpenAMのグループに対して、利用できるアカウントとロールを付けていく管理がいいのだろうか。
OpenAMがまるで詳しくないので、次いきます。

G SuiteをIdpとしてAWSにSAML認証でログインする

IDaaSとしてはOneloginなど他にもありますが、G Suiteが試しやすかったので、こちらを採用。
G SuiteからSAML認証でAWSにログインするまでの手順は、こちらにまとまっているので、ここでは説明を割愛します。

MasterアカウントのIdpとして登録

G_Suite.png

OpenAMの代わりにIDaaSとしてG Suiteを利用した構成。

G SuiteのBusinessプラン以上でないと監査ログが取れないので、できればBussinessプランにしたい。Basicプランでも構成自体は実現できる。

SubアカウントのIdpとして登録

G_Suite2.png

G Suiteはひとつのアプリから、下記のどの構成もいけるのでMasterアカウントを経由する方法を取る必要はなさそう。

  • Single APP -> Single Account Single Role
  • Single APP -> Single Account Multi Role
  • Single APP -> Multi Account Multi Role

G SuiteユーザーのAWS Console Roleのrole属性に roleのarn,Idpのarn の形で記載する。roleは複数値入れられるように設定されているので、別のSubアカウントの権限も与えたい場合は、これを増やしていけばいい。

管理コンソール.png

G Suiteのアプリを選択すると、このようにSwith Roleの選択画面に飛ぶ。

Amazon Web Services Sign In.png

アカウントがIDなのはどうしようもなさそうだが、Role名を工夫すればどのサービスのアカウントか判別できそう。
Role名を統一したい場合は、Chromeの拡張機能とか作ってAWSアカウントIDと名前を置換するとか。
あとで困りそうだけどサービスごとにG Suiteのアプリを分けてしまう手もある。

各Subアカウントに対してIdpを設定する必要があるが、Cloud Formationでかなりの部分は吸収できるし、そもそもアカウントをそんなにぽんぽん増やすシーンも思いつかないので、その管理コストよりも利用者の日々の手間をワンステップ減らしたほうが利はあると思う。

GAMでG SuiteのユーザーにAWSの権限を与える

人が増えたり減ったり入れ替わりが起きるごとに、G SuiteのAWS Console Roleを変更するのは辛いので自動化を目指します。
GAMを使えばG Suite APIをCLIで簡単に操作できるので、これを使います。

インストールから基本的な使い方は、以下に詳しく書いてあるので割愛します。

今回修正がしたいのはCustom User Schema Fieldなのでマニュアルはこれ。

試しにさっきのユーザーを 54321 をなくして、 33333 をいうAWSアカウントIDに権限を付けてみます。
注意点としては追加削除という概念はなく、指定したものを上書きする形で指定します。

# gam update user username@example.com \
AWS_Console_Role.role multivalued arn:aws:iam::12345:role/CrossAccountManager-Administrator,arn:aws:iam::12345:saml-provider/G-Suite \
AWS_Console_Role.role multivalued arn:aws:iam::33333:role/CrossAccountManager-Developer,arn:aws:iam::33333:saml-provider/G-Suit
updating user username@example.com...

管理コンソール2.png

ちゃんと更新できてますね。

自動化について

ユーザーごとに管理するのは大変なので、グループごとにアカウントとロールを管理して、そのマスターが更新されるかグループのメンバーが更新されたら、functionが起動してグループ内ユーザーのroleを更新してくれる的なものまでいければ完璧ですが、まだ試していないので今回はここまで。

続きを読む

AWS X-Rayで非同期メッセージをトレースする

Microservicesを構築するにあたり、各サービス間のメッセージのやりとりに、非同期処理を使うことがあると思います。

その非同期処理をぱっと簡単にトレースしたいとかってあると思いますが、それを、Serverlessにやってみたってお話です。

やりたいこと

image1.png

こんな流れを想定しています。

最初のLambdaから、SQSにメッセージを送信し、SQSをcronで起動されたLambdaがポーリングして、メッセージを取得したあと、なんしかの処理をした後、DynamoDBにデータを永続化します。

実際のX-Ray

AWS_X-Ray.png

こんな感じで出力されます。

ぱっと見でうまいことトレースができている様子ですが、実際には、publishしたメッセージがsubscribeした側との繋りがなく、トレースができているようでいて、できていません。

AWS_X-Ray.png

上図のように、publishのトレースを見てみると、SQSにメッセージを送信した時点で切れています。

トレースできるようにする方法

pubしたメッセージを実際にDynamoDBに保存されるまでトレースするには、以下のような構成を取るといけます。

AWS Icons(1).png

Lambda1でpubする方法

Lambda1でメッセージを送信する際に、Lambda実行時の環境変数から、TraceIdを取り出して、SQSのメッセージに加えます。

Scalaで書くとこんな感じ。

val sqs = AmazonSQSClient.builder()
  .withRegion(region)
  .withRequestHandlers(new TracingHandler(AWSXRay.getGlobalRecorder))
  .build()

val traceId = sys.env("_X_AMZN_TRACE_ID")

sqs.sendMessage(queueUrl, traceId)

Lambda2でsubする方法

Lambda2でsubするときに、SQSのメッセージからTraceIdを取得して、Lambda3をinvokeします。

Scalaで書くとこんな感じ。

val clientConfiguration = new ClientConfiguration()
clientConfiguration.addHeader("x-amzn-trace-id", message.getBody)

val lambda = AWSLambdaClient.builder()
  .withRegion(region)
  .withClientConfiguration(clientConfiguration)
  .build

val invokeRequest = new InvokeRequest()
  .withFunctionName(functionName)
  .withPayload(s"""{"id":"${message.getBody}"}""")
  .withLogType(LogType.Tail)

lambda.invoke(invokeRequest)

トレースできてるX-Ray

この手法だと、以下のよーにトレースできています。

AWS_X-Ray.png

ちょっと複雑な感じに見えますが、これでメッセージをpubしてからDynamoDBにputするまでトレースできています。

AWS_X-Ray.png

このように、pubしたものが、38.5sかけて、subされてDynamoDBにputされている様子が見えます。

まとめ

非同期を可視化してトレースできるっていうのは、Microservicesを運用していくうえで、けっこー重要な可視化ですよね。

それをX-Rayで実現していくのに、こういった方法が取れます。

とはいえ、Lambda2 => Lambda3を起動するということで、レイテンシーの低下がありますから、そのあたりが許容できるかどうかは、要件次第でもあります。

X-Rayが改善されて、このような工夫をしなくてもトレースできることを期待しています。

以上でっす。

検証でつくったGitHub

https://github.com/yoshiyoshifujii/async-trace-by-aws-x-ray

続きを読む

CloudFormationを使って一撃で作るAssumeRoleによる強い💪権限管理

インフラエンジニアのみつの(@kotatsu360)です。
この記事はVASILY Advent Calendar 201710日目の記事です。

この記事では、AWSのIAMでAssumeRoleする権限周りを一撃でつくるCloudFormationテンプレートをまとめます。ご査収ください。

CloudFormationが作る環境のイメージ
image.png

このドキュメントの目的

IAMは調べれば調べるほど情報が断片的になる。あとJSON書くの面倒(一一 )
CloudFormationならYAMLで書けるし再現性がある。テンプレートにまとめようヽ(゚∀゚)ノ パッ☆

AssumeRoleとは

  1. 役割ごとに必要な権限を持ったIAMロールを作っておく

    • 開発
    • 運用
    • 請求
    • 監査
    • etc…
  2. IAMロールごとに「自分の権限を使うことを許可する」対象を設定しておく
    • 開発用のロールはエンジニアAとBとCと…
    • IAMロール「力がほしい時はいつでも我が名を呼ぶが良い」
  3. つかう
    • 変身
    • 普段はブラックRX。たまにはバイオライダー。

メリット

  • 権限をロールという単位で整理できる

    • 個人ごとに細かく設定するとスパゲッティになりがち
  • 場面場面で必要な権限だけが使える
    • 「開発時に誤って本番構成を変更してしまって泣く」ということが減る
  • 権限の確認が楽
    • 個人ごとに設定すると、権限付与者(おそらく強い権限持ち)と非付与者の差異が生まれる
    • AssumeRoleであれば、そのロールにスイッチして自分で権限を確認できる

デメリット

  • めんどう

    • 「rootアカウントでいいじゃん」と言われたらそこまで・・・
    • アカウント管理をやりたい時はとてもオススメ

なお、後から入れるのはカロリーを消費するので導入は早い方が良いと思います。
また、CloudFormationを使うとコード管理できるので、かなり楽になります。

CloudFormationテンプレートで実行されること

  • IAMユーザの作成

    • ユーザは4人
    • 開発者
    • 開発者&本番運用者
    • 開発者&本番運用者
    • 請求管理者
    • 初ログイン時にパスワード変更画面を出す
  • IAMグループの作成
    • 全員用のグループ
    • 本番運用者用のグループ
  • IAMロールの作成
    • 開発者用の権限を管理するロール
    • 本番運用者用の権限を管理するロール
    • 請求管理者用の権限を管理するロール
  • ユーザをグループに追加

グループとロールの住み分けについては後で触れます。

CloudFormationテンプレート

初期パスワードが小文字と記号になっているので、アカウントのパスワードポリシーによっては作成に失敗します。

AWSTemplateFormatVersion: 2010-09-09
Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: 'IAM User Name'
        Parameters:
          - Dev1
          - Prod1
          - Prod2
          - Billing1
Parameters:
  Dev1:
    Type: String
    Default: 'kotatsu360a'
  Prod1:
    Type: String
    Default: 'kotatsu360b'
  Prod2:
    Type: String
    Default: 'kotatsu360c'
  Billing1:
    Type: String
    Default: 'kotatsu360d'
Resources:
  # ユーザ全員が所属するグループ
  IAMGroupAllMember:
    Type: 'AWS::IAM::Group'
    Properties:
      Policies:
        - PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: 'Allow'
                Action:
                  - 'iam:List*'
                Resource: '*'
              - Effect: 'Allow'
                Action:
                  - 'iam:GetLoginProfile'
                  - 'iam:ChangePassword'
                  - 'iam:CreateVirtualMFADevice'
                  - 'iam:DeleteVirtualMFADevice'
                  - 'iam:EnableMFADevice'
                Resource: 
                  - !Sub 'arn:aws:iam::${AWS::AccountId}:mfa/${!aws:username}'
                  - !Sub 'arn:aws:iam::${AWS::AccountId}:user/${!aws:username}'
             - Effect: 'Allow'
                Action:
                  - 'am:DeactivateMFADevice'
                Resource:
                  - !Sub 'arn:aws:iam::${AWS::AccountId}:mfa/${!aws:username}'
                  - !Sub 'arn:aws:iam::${AWS::AccountId}:user/${!aws:username}'
                Condition:
                  Bool:
                    aws:MultiFactorAuthPresent: true
          PolicyName: 'mfa-attach'

  # 本番運用者が所属するグループ
  IAMGroupProductionOperator:
    Type: 'AWS::IAM::Group'
    Properties:
      Policies:
        - PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: 'Allow'
                Action:
                  - 'opsworks:UpdateMyUserProfile'
                  - 'opsworks:Describe*'
                Resource: '*'
          PolicyName: 'change-my-ssh-key'

  # [NOTE] 開発者と請求管理者については個人宛のルールが必要ないのでグループを作らない

  # 開発者の権限を管理するロール
  IAMRoleDeveloper:
    Type: 'AWS::IAM::Role'
    Properties:
      RoleName: 'developer'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              AWS:
                - !GetAtt IAMUserDev1.Arn
                - !GetAtt IAMUserProd1.Arn
                - !GetAtt IAMUserProd2.Arn
            Action: 'sts:AssumeRole'
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/ReadOnlyAccess'

  # 本番運用者の権限を管理するロール
  IAMRoleProductionOperator:
    Type: 'AWS::IAM::Role'
    Properties:
      RoleName: 'production-operator'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              AWS:
                - !GetAtt IAMUserProd1.Arn
                - !GetAtt IAMUserProd2.Arn
            Action: 'sts:AssumeRole'
            Condition:
              Bool:
                aws:MultiFactorAuthPresent: true
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/ReadOnlyAccess'
        - 'arn:aws:iam::aws:policy/AmazonS3FullAccess'

  # 請求管理者の権限を管理するロール
  IAMRoleBillingOwner:
    Type: "AWS::IAM::Role"
    Properties:
      RoleName: 'billing-owner'
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              AWS:
                - !GetAtt IAMUserBilling1.Arn
            Action: 'sts:AssumeRole'
            Condition:
              Bool:
                aws:MultiFactorAuthPresent: true
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/job-function/Billing'

  IAMUserDev1:
    Type: 'AWS::IAM::User'
    Properties:
      LoginProfile:
        Password: 'i_love_whisky'
        PasswordResetRequired: true
      UserName: !Ref Dev1

  IAMUserProd1:
    Type: 'AWS::IAM::User'
    Properties:
      LoginProfile:
        Password: 'i_love_cigar'
        PasswordResetRequired: true
      UserName: !Ref Prod1

  IAMUserProd2:
    Type: 'AWS::IAM::User'
    Properties:
      LoginProfile:
        Password: 'i_love_sleeping'
        PasswordResetRequired: true
      UserName: !Ref Prod2

  IAMUserBilling1:
    Type: 'AWS::IAM::User'
    Properties:
      LoginProfile:
        Password: 'i_love_money'
        PasswordResetRequired: true
      UserName: !Ref Billing1

  IAMUserToGroupAdditionAllMember:
    Type: 'AWS::IAM::UserToGroupAddition'
    Properties:
      GroupName: !Ref IAMGroupAllMember
      Users:
        - !Ref IAMUserDev1
        - !Ref IAMUserProd1
        - !Ref IAMUserProd2
        - !Ref IAMUserBilling1

  IAMUserToGroupAdditionProductionOperator:
    Type: 'AWS::IAM::UserToGroupAddition'
    Properties:
      GroupName: !Ref IAMGroupAllMember
      Users:
        - !Ref IAMUserProd1
        - !Ref IAMUserProd2

権限の補足

IAMグループの使いどころ

権限は次の方針で設定しています。

  • ユーザ自体には何の権限も与えない
  • IAMロールで権限管理

しかし、幾つかの権限はロール管理が難しいです。

  • 自分のパスワードの変更

    • 特に、初ログイン時のパスワード変更はユーザ権限での操作以外ムリ
  • 自分のMFA設定
  • 自分のSSH鍵管理
    • OpsWorksを使う場合

「自分の」という部分が曲者です。単に権限を与えるだけでなく、「自分だけできる」というのが重要です。
そのため、これら「自分の情報だけに対して更新権限を与えたい」という場合はIAMグループを使って権限付与しています。

              - Effect: 'Allow'
                Action:
                  - 'iam:GetLoginProfile'
                  - 'iam:ChangePassword'
                  - 'iam:CreateVirtualMFADevice'
                  - 'iam:DeleteVirtualMFADevice'
                  - 'iam:EnableMFADevice'
                Resource: 
                  - !Sub 'arn:aws:iam::${AWS::AccountId}:mfa/${!aws:username}' # 自分のMFAだけを対象に
                  - !Sub 'arn:aws:iam::${AWS::AccountId}:user/${!aws:username} # 自分のユーザ情報だけを対象に

OpsWorksはActionレベルで自分だけに対してのものを用意してくれているのでそれを使います。

              - Effect: 'Allow'
                Action:
                  - 'opsworks:UpdateMyUserProfile' # 自分の情報だけの更新権限, 参照権限版もある(DescribeMyUserProfile)
                  - 'opsworks:Describe*'
                Resource: '*'

AssumeRoleの条件

AssumeRoleは、ユーザの指定以外にも特定の条件を満たした場合のみ、という制約をつけることができます。

本番運用者と請求管理者にはユーザの指定以外にMFAでログインしている場合のみAssumeRoleを許可するという制約をつけました。

      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              AWS:
                - !GetAtt IAMUserBilling1.Arn
            Action: 'sts:AssumeRole'
            Condition:
              Bool:
                aws:MultiFactorAuthPresent: true # MFAログインでないとConditionが満たせずAssumeRoleできない

さいごに

AssumeRoleを理解してからは権限管理の世界が広がった感があります。
ぜひ権限を追加して、実態に即したロールに育ててください。

さいごのさいごに

(IAMの具体的な使い方を紹介する記事がもっと増えますように)

続きを読む

AWS IoTのThing Shadowを気軽に試してみる with mosquitto

AWS IoTを色々と試してはいたのですが、よく目にするThing Shadowに関しては説明文だけではいまいちピンときませんでした。とりあえずshadowを触ってみたら、仕組みや出来ることのイメージがつきました。
簡単に要点を5つ書きます。

  • Thing Shadowは、デバイスの状態を管理することのできる機能。
  • Thing Shadowで使えるtopic名は、あらかじめ決まっている。(記事投稿時点で、11個⇒一応全部試した結果を下に書きました)
  • Thing Shadowへデバイスの状態を送るには、JSON形式で”state”セクションを記述したものをpublishする。
  • 上記”state”セクションには、デバイスの現在の状態を通知するための”reported”セクションと、デバイスへの指示を通知するための”desired”セクションが存在する。
  • Thing Shadowへのsubscribeによって受け取ったJSONファイルには、デバイスの状態”state”の他、タイムスタンプなどの”metadata”が含まれる。

ここでは気軽にmosquittoを使って試した結果を載せます。

前提条件は、
・mosquitto-clientsを利用して、AWS IoTとのPub/Subができていること
https://qiita.com/TKYK38/items/2ca19b27503f6e0dfc84
です。

AWSコンソールでシャドウドキュメントを開いておく

これを開きながら作業すると、状態が更新されていくところがよく理解できます。
(IoTコンソール > 管理 > モノ > シャドウ で開けます)
shadow_mod.png

publish用のサンプルスクリプト

pub.shadow.sh
#!/bin/bash

endpoint=`aws iot describe-endpoint`

keydir=/home/ubuntu/iot_sdk

### update
topic=$aws/things/EC2_ubuntu/shadow/update

### get
#topic=$aws/things/EC2_ubuntu/shadow/get

### delete
#topic=$aws/things/EC2_ubuntu/shadow/delete

#ifile=shadow.desired.json
ifile=shadow.reported.json

mosquitto_pub --cafile ${keydir}/root-CA.crt --cert ${keydir}/EC2_ubuntu.cert.pem --key ${keydir}/EC2_ubuntu.private.key -h "${endpoint}" -p 8883 -q 1 -t ${topic} -i mosquitto_pub -f ${ifile}

subscribe用のサンプルスクリプト

sub.shadow.sh
endpoint=`aws iot describe-endpoint`

keydir=/home/ubuntu/iot_sdk

topic=$aws/things/EC2_ubuntu/shadow/update
#topic=$aws/things/EC2_ubuntu/shadow/update/accepted
#topic=$aws/things/EC2_ubuntu/shadow/update/documents
#topic=$aws/things/EC2_ubuntu/shadow/update/rejected
#topic=$aws/things/EC2_ubuntu/shadow/update/delta
#topic=$aws/things/EC2_ubuntu/shadow/get
#topic=$aws/things/EC2_ubuntu/shadow/get/accepted
#topic=$aws/things/EC2_ubuntu/shadow/get/rejected
#topic=$aws/things/EC2_ubuntu/shadow/delete
#topic=$aws/things/EC2_ubuntu/shadow/delete/accepted
#topic=$aws/things/EC2_ubuntu/shadow/delete/rejected

mosquitto_sub --cafile ${cadir}/root-CA.crt --cert ${keydir}/tmp.cert.txt --key ${keydir}/tmp.private.txt -h "${endpoint}" -p 8883 -q 1 -t ${topic}

デバイス状態を通知するためのJSONサンプル

shadow.reported.json
{
    "state" : {
        "reported" : {
            "color" : "yellow",
            "power" : "on"
         }
     }
}

デバイスへ指示(状態更新)するためのJSONサンプル

shadow.desired.json
{
    "state" : {
        "desired" : {
            "color" : "red",
            "power" : "on"
         }
     }
}

デバイス状態の更新 /update

/update/accepted をsubした状態で、/updateへshadow.reported.jsonをpubする

以下のようなJSONをsubscribeできる。

{"state":{"reported":{"color":"yellow","power":"on"}},"metadata":{"reported":{"color":{"timestamp":1512807898},"power":{"timestamp":1512807898}}},"version":13,"timestamp":1512807898}

/update/documents をsubした状態で、/updateへshadow.reported.jsonをpubする

以下のようなJSONをsubscribeできる。

{"previous":{"state":{"desired":{"color":"red","power":"on"},"reported":{"color":"blue","power":"on"}},"metadata":{"desired":{"color":{"timestamp":1512805884},"power":{"timestamp":1512805884}},"reported":{"color":{"timestamp":1512808170},"power":{"timestamp":1512808170}}},"version":17},"current":{"state":{"desired":{"color":"red","power":"on"},"reported":{"color":"blue","power":"on"}},"metadata":{"desired":{"color":{"timestamp":1512805884},"power":{"timestamp":1512805884}},"reported":{"color":{"timestamp":1512808184},"power":{"timestamp":1512808184}}},"version":18},"timestamp":1512808184}

/update/rejected をsubした状態で、/updateへshadow.reported.jsonをpubする

例えば、”reported”を”report”と変更し、pubしてみると、以下のようなエラー(JSON)をsubscribeできる。

{"code":400,"message":"State contains an invalid node: 'report'"}

/update/delta をsubした状態で、/updateへshadow.reported.jsonをpubする

reportedとdesiredの差分がある場合にsubscribeできる。

{"version":21,"timestamp":1512808331,"state":{"color":"red"},"metadata":{"color":{"timestamp":1512805884}}}

デバイス状態の取得 /get

/get/accepted をsubした状態で、/getへメッセージをpubする

/getへpubすると、desiredとreported両方の情報をsubscribeできる。
(/getへpubするメッセージは何でもよい。)

{"state":{"desired":{"color":"red","power":"on"},"reported":{"color":"yellow","power":"on"}},"metadata":{"desired":{"color":{"timestamp":1512805884},"power":{"timestamp":1512805884}},"reported":{"color":{"timestamp":1512808392},"power":{"timestamp":1512808392}}},"version":23,"timestamp":1512808573}

/get/rejected をsubした状態で、/getへメッセージをpubする

shadowが存在しない状態で、/getへpubすると、以下のようなエラー(JSON)をsubscribeできる。

{"code":404,"message":"No shadow exists with name: 'EC2_ubuntu'"}

デバイス状態の削除 /delete

/delete/accepted をsubした状態で、/deleteへメッセージをpubする

shadowが削除されると、以下のようなJSONをsubscribeできる。

{"version":23,"timestamp":1512809286}

/delete/rejected をsubした状態で、/deleteへメッセージをpubする

shadowが存在しない状態で、shadowを削除しようとすると、以下のようなエラー(JSON)をsubscribeできる。

{"code":404,"message":"No shadow exists with name: 'EC2_ubuntu'"}

参考情報

AWS IoTの開発者ガイドです。
Thing Shadow の MQTT トピック

続きを読む

CloudFormationとecs-cliを使って「Fargate+NLB+fluentd」環境を作成する。

AWS Fargateが発表されたのでさっそくつかってみます。
今回はFargateの上でfluentdコンテナを実行します。

手順

  1. CloudFromationのテンプレートでECSクラスタやNLBなどのAWSリソースを作ります。
  2. fluentdのDockerfiledocker-compose.ymlecs-params.ymlを作ります。
  3. fluentdのDockerイメージを作成し、ECRにpushします。
  4. ecs-cliコマンドでFargateにデプロイします。
  5. ecs-cli logsコマンドで接続を受け付けていることを確認します。

CloudFormationテンプレート

  • VPCから一気に作ります。
AWSTemplateFormatVersion: '2010-09-09'

Description: ""

Parameters:
  VPCCidr:
    Description: ""
    Type: String
    Default: "10.1.0.0/16"
  EnableDnsSupport:
    Description: ""
    Type: String
    Default: true
    AllowedValues:
      - true
      - false
  EnableDnsHostnames:
    Description: ""
    Type: String
    Default: true
    AllowedValues:
      - true
      - false
  InstanceTenancy:
    Description: ""
    Type: String
    Default: default
    AllowedValues:
      - default
      - dedicated
  SubnetCidrA:
    Description: ""
    Type: String
    Default: "10.1.10.0/24"
  SubnetCidrB:
    Description: ""
    Type: String
    Default: "10.1.20.0/24"
  AvailabilityZoneA:
    Description: ""
    Type: String
    Default: us-east-1a
  AvailabilityZoneB:
    Description: ""
    Type: String
    Default: us-east-1b
  MapPublicIpOnLaunch:
    Description: ""
    Type: String
    Default: true
    AllowedValues:
      - true
      - false
  AttachInternetGateway:
    Description: ""
    Type: String
    Default: "true"
    AllowedValues:
      - true
      - false
  Schema:
    Description: ""
    Type: String
    Default: "internet-facing"
    AllowedValues:
      - "internet-facing"
      - "internal"
  ListenerPort:
    Description: ""
    Type: String
    Default: "24224"
  TargetGroupPort:
    Description: ""
    Type: String
    Default: "24224"
  TargetType:
    Description: ""
    Type: String
    Default: "ip"
    AllowedValues:
      - "ip"
      - "instance"

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: "Network Configuration"
        Parameters:
          - VPCCidr
          - EnableDnsSupport
          - EnableDnsHostnames
          - InstanceTenancy
          - SubnetCidrA
          - SubnetCidrB
          - AvailabilityZoneA
          - AvailabilityZoneB
          - MapPublicIpOnLaunch
          - AttachInternetGateway
      - Label:
          default: "NetworkLoadBalancer Configuration"
        Parameters:
          - Schema
          - ListenerPort
          - TargetGroupPort
          - TargetType

Outputs:
  Cluster:
    Value: !Ref Cluster
  TargetGroupArn:
    Value: !Ref TargetGroup
  SubnetA:
    Value: !Ref SubnetA
  SubnetB:
    Value: !Ref SubnetB
  SecurityGroup:
    Value: !GetAtt SecurityGroup.GroupId
  IAMRole:
    Value: !GetAtt IAMRole.Arn
  Repository:
    Value: !Ref Repository

Resources:
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VPCCidr
      EnableDnsSupport: !Ref EnableDnsSupport
      EnableDnsHostnames: !Ref EnableDnsHostnames
      InstanceTenancy: !Ref InstanceTenancy
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}.vpc"

  RouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}.rtable"

  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}.igw"

  AttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      InternetGatewayId: !Ref InternetGateway
      VpcId: !Ref VPC

  Route:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref RouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway

  SubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: !Ref SubnetCidrA
      AvailabilityZone: !Ref AvailabilityZoneA
      MapPublicIpOnLaunch: !Ref MapPublicIpOnLaunch
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}.subnet"

  SubnetARouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref SubnetA
      RouteTableId: !Ref RouteTable

  SubnetB:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: !Ref SubnetCidrB
      AvailabilityZone: !Ref AvailabilityZoneB
      MapPublicIpOnLaunch: !Ref MapPublicIpOnLaunch
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}.subnet"

  SubnetBRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref SubnetB
      RouteTableId: !Ref RouteTable

  SecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: "SecurityGroup"
      VpcId: !Ref VPC
      SecurityGroupIngress:
        - CidrIp: !Ref VPCCidr
          IpProtocol: "tcp"
          FromPort: "24224"
          ToPort: "24224"
        - CidrIp: 0.0.0.0/0
          IpProtocol: "tcp"
          FromPort: "24224"
          ToPort: "24224"
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}.sg"

  Cluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Ref AWS::StackName

  LoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: !Sub "${AWS::StackName}-nlb"
      Type: "network"
      Scheme: !Ref Schema
      Subnets:
        - !Ref SubnetA
        - !Ref SubnetB
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}.nlb"

  Listener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref LoadBalancer
      Port: !Ref ListenerPort
      Protocol: "TCP"
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref TargetGroup

  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    DependsOn: LoadBalancer
    Properties:
      Name: !Sub "${AWS::StackName}-tg"
      Port: !Ref TargetGroupPort
      Protocol: "TCP"
      VpcId: !Ref VPC
      TargetType: !Ref TargetType
      Tags:
        - Key: Name
          Value: !Sub "${AWS::StackName}.targetgroup"

  IAMRole:
    Type: "AWS::IAM::Role"
    Properties:
      RoleName: !Sub "${AWS::StackName}.role"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Action:
              - "sts:AssumeRole"
            Principal:
              Service:
                - "ecs-tasks.amazonaws.com"
      Policies:
        - PolicyName: !Sub "${AWS::StackName}.policy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: "Allow"
                Resource: "*"
                Action:
                  - "ecr:GetAuthorizationToken"
                  - "ecr:BatchCheckLayerAvailability"
                  - "ecr:GetDownloadUrlForLayer"
                  - "ecr:BatchGetImage"
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"

  LogGroup:
    Type: "AWS::Logs::LogGroup"
    Properties:
      LogGroupName: !Sub "${AWS::StackName}/fluentd"
      RetentionInDays: 3

  Repository:
    Type: "AWS::ECR::Repository"
    Properties:
      RepositoryName: !Sub "${AWS::StackName}/fluentd"
  • ポイント

    1. NLBにはSecurityGroupはアタッチできない。
    2. コンテナにはVPC Cidrからのアクセスを許可する必要がある。これがないとヘルスチェックが失敗する。
    3. コンテナはロググループを自動生成しない。あらかじめロググループを作成しておく必要がある。

Stackの作成

  • AWSコンソールからCloudFormationを選択してStackを作成します。

cfn

  • しばらく待ちます。

creating

  • 完成です。

created

コンテナの設定

  • 以下のディレクトリ構成で設定ファイルを書きます。
├── Makefile
└── fluentd
    ├── Dockerfile
    ├── docker-compose.yml
    ├── ecs-params.yml
    ├── fluent.conf
    └── plugins //今回は空ディレクトリでok

fluent.conf

  • 標準出力に吐き出すだけです。
  • CloudWatchLogsに転送されます。
<source>
  type forward
  bind 0.0.0.0
  port 24224
</source>

<match **>
  type stdout
</match>

Dockerfile

FROM fluent/fluentd
COPY fluent.conf /fluentd/etc

docker-compose.yml

  • ${AWS::Account}はAWSアカウントID、${AWS::StackName}はCloudFormationで作成したスタックの名前を入れます。
version: '2'
services:
  fluentd:
    image: ${AWS::Account}.dkr.ecr.us-east-1.amazonaws.com/${AWS::StackName}/fluentd
    ports:
      - "24224:24224"
    logging:
      driver: "awslogs"
      options:
        awslogs-region: "us-east-1"
        awslogs-group: "${AWS::StackName}/fluentd"
        awslogs-stream-prefix: "container"

ecs-params.yml

  • ${SubnetA}, ${SubnetB}, ${SecurityGroup}はCloudFormationで作成したリソースのIDを入れます。
  • CloudFormationの「出力」タブに作成したリソースの一覧が表示されているのでコピペします。
  • 自動生成してほしい。
version: 1
task_definition:
  ecs_network_mode: awsvpc
  task_execution_role: arn:aws:iam::${AWS::Account}:role/${AWS::StackName}.role
  task_size:
    cpu_limit: 0.25
    mem_limit: 0.5GB
  services:
    fluentd:
      essential: true

run_params:
  network_configuration:
    awsvpc_configuration:
      subnets:
        - ${SubnetA}
        - ${SubnetB}
      security_groups:
        - ${SecurityGroup}
      assign_public_ip: ENABLED
  • この設定ファイルをみて分かる通り、Fargate(正確にはawsvpcモード)ではコンテナに直接サブネットやセキュリティグループをアタッチします。
  • EC2インスタンスのような感覚でコンテナを扱えます。

Makefile

  • たくさんコマンドをうつのでMakefileをつくっておきます。
push:
    docker build -f fluentd/Dockerfile -t ${AWS::Account}.dkr.ecr.us-east-1.amazonaws.com/${AWS::StackName}/fluentd fluentd
    `aws ecr get-login --no-include-email --region us-east-1`
    docker push ${AWS::Account}.dkr.ecr.us-east-1.amazonaws.com/${AWS::StackName}/fluentd:latest

up:
    cd fluentd; 
    ecs-cli compose service up 
        --cluster ${AWS::StackName} 
        --target-group-arn ${TargetGroupArn} 
        --launch-type FARGATE 
        --container-name fluentd 
        --container-port 24224 
        --region us-east-1 
        --timeout 10

rm:
    cd fluentd; 
    ecs-cli compose service rm 
    --cluster ${AWS::StackName} 
    --region us-east-1 
    --timeout 10

Deploy

  • イメージを作ってECRにpushします。
$ make push
docker build -f fluentd/Dockerfile -t ************.dkr.ecr.us-east-1.amazonaws.com/fargate/fluentd fluentd
Sending build context to Docker daemon  5.632kB
Step 1/2 : FROM fluent/fluentd
 ---> 060874232311
Step 2/2 : COPY fluent.conf /fluentd/etc
 ---> Using cache
 ---> 1ee08befeb8d
Successfully built 1ee08befeb8d
Successfully tagged ************.dkr.ecr.us-east-1.amazonaws.com/fargate/fluentd:latest
`aws ecr get-login --no-include-email --region us-east-1`
WARNING! Using --password via the CLI is insecure. Use --password-stdin.
Login Succeeded
docker push ************.dkr.ecr.us-east-1.amazonaws.com/fargate/fluentd:latest
The push refers to a repository [************.dkr.ecr.us-east-1.amazonaws.com/fargate/fluentd]
baa346e06fe3: Pushed 
fe129fa31f70: Pushed 
dcf88bef8f3a: Pushed 
b59190601542: Pushed 
56e1e5a28df0: Pushed 
9fc62b353b50: Pushed 
26fbe6ae586e: Pushed 
16174e87921f: Pushed 
latest: digest: sha256:9f8c90b5fc10c084f93c5a93c038f4d307676b4fb641a8a36d67f4573655d52f size: 1981
  • Fargateにデプロイします。
$ make up
cd fluentd; 
    ecs-cli compose service up 
    --cluster fargate 
    --target-group-arn arn:aws:elasticloadbalancing:us-east-1:************:targetgroup/fargate-tg/**** 
    --launch-type FARGATE 
    --container-name fluentd 
    --container-port 24224 
    --region us-east-1 
    --timeout 10
WARN[0000] Skipping unsupported YAML option...           option name=networks
WARN[0000] Skipping unsupported YAML option for service...  option name=networks service name=fluentd
INFO[0001] Using ECS task definition                     TaskDefinition="fluentd:12"
INFO[0002] Created an ECS service                        service=fluentd taskDefinition="fluentd:12"
INFO[0002] Updated ECS service successfully              desiredCount=1 serviceName=fluentd
INFO[0017] (service fluentd) has started 1 tasks: (task 7228958b-0de1-4e31-a6b2-52d35b6c7b84).  timestamp=2017-12-07 02:14:52 +0000 UTC
INFO[0139] Service status                                desiredCount=1 runningCount=1 serviceName=fluentd
INFO[0139] ECS Service has reached a stable state        desiredCount=1 runningCount=1 serviceName=fluentd

確認

  • ログを取得します。
$ ecs-cli ps --cluster fargate --region us-east-1
Name                                          State                Ports                         TaskDefinition
7228958b-0de1-4e31-a6b2-52d35b6c7b84/fluentd  RUNNING              **.**.**.**:24224->24224/tcp  fluentd:12

$ ecs-cli logs --cluster fargate --region us-east-1 --task-id 7228958b-0de1-4e31-a6b2-52d35b6c7b84
2017-12-07 02:17:03 +0000 [info]: reading config file path="/fluentd/etc/fluent.conf"
2017-12-07 02:17:03 +0000 [info]: starting fluentd-0.12.40
2017-12-07 02:17:03 +0000 [info]: gem 'fluentd' version '0.12.40'
2017-12-07 02:17:03 +0000 [info]: adding match pattern="**" type="stdout"
2017-12-07 02:17:03 +0000 [info]: adding source type="forward"
2017-12-07 02:17:03 +0000 [info]: using configuration file: <ROOT>
  <source>
    type forward
    bind 0.0.0.0
    port 24224
  </source>

  <match **>
    type stdout
  </match>
</ROOT>
2017-12-07 02:17:03 +0000 [info]: listening fluent socket on 0.0.0.0:24224
  • 正常に起動しています。
  • fluent-cattelnetで接続するとログが出力されます。

削除

  • 作成したリソースを消しておかないとお金を取られます。
  • コンテナを削除してから、AWSリソースを削除します。
$ make rm
cd fluentd; 
    ecs-cli compose service rm 
    --cluster fargate 
    --region us-east-1 
    --timeout 10
WARN[0000] Skipping unsupported YAML option...           option name=networks
WARN[0000] Skipping unsupported YAML option for service...  option name=networks service name=fluentd
INFO[0001] Updated ECS service successfully              desiredCount=0 serviceName=fluentd
INFO[0001] Service status                                desiredCount=0 runningCount=1 serviceName=fluentd
INFO[0017] (service fluentd) has begun draining connections on 1 tasks.  timestamp=2017-12-07 02:44:53 +0000 UTC
INFO[0017] (service fluentd) deregistered 1 targets in (target-group arn:aws:elasticloadbalancing:us-east-1:************:targetgroup/fargate-tg/****)  timestamp=2017-12-07 02:44:53 +0000 UTC
INFO[0321] Service status                                desiredCount=0 runningCount=0 serviceName=fluentd
INFO[0321] ECS Service has reached a stable state        desiredCount=0 runningCount=0 serviceName=fluentd
INFO[0321] Deleted ECS service                           service=fluentd
INFO[0322] ECS Service has reached a stable state        desiredCount=0 runningCount=0 serviceName=fluentd
  • AWSコンソールでECRリポジトリを削除します。

    • CloudFromationで作成したリポジトリにイメージが登録されていると、CloudFormationでは削除できません。
  • AWSコンソール -> CloudFromation -> Stackの削除をします。

使ってみた感想

今まではECSクラスタを構成するEC2インスタンスの制限を受けて使いにくい部分(動的ポートマッピングとかawsvpcのeni制限とかスケールとか)がありましたが、Fargateによってそれらが一気に解決しました。AWSでコンテナ運用するならFargate一択ですね。

参考

  1. AWS Fargate: サービス概要
  2. GitHub – aws/amazon-ecs-cli

続きを読む

Amazon SESで受信したメールを転送したい

前置き

SESで受信する特定のアドレス宛のメールを外部の別アドレスへ転送したい。SESの設定でこのへんをポチポチっとやれば…と思ったが、できない。できそうでできない。
そう、SESの標準機能ではメールの転送はできない。

対応方針

思い当たる案は2つ。
1. 受信メッセージをS3へ出力し、それをLambdaで拾って別アドレスへ送信する案
2. Amazon WorkMailを使う案
Lambdaを使う案が主流かもしれないが、自分の場合は品質とスピードを重視して結果的には案2を採用した。

案1 S3への出力とLambdaを使う

SESの受信ルールで、受信メッセージをS3へ出力し、それをLambdaでどうにかして別アドレスへメール送信するのが定石のようだ。

こういった情報を参考にプログラムを作成することになるのだが、文字コードや添付ファイルの有無、メッセージを編集してから転送したい、などいろいろ考慮していくとMIMEの深遠なる世界に迷い込んでなかなか大変なことになる。

加えて、SESのS3アクションで「Encrypt Message」オプションを有効化するとS3に格納するメッセージデータが暗号化されるが、それをLambda(Python)側で複合する方法がわからずハマった。というか今も解決できていない。(KMSも絡んで話が長くなるのでこれについてはあらためて別の記事にしたい。)
公式ドキュメント

スクリプト

lambda_function.py
# -*- coding: utf-8 -*-
import boto3
import json
import re
import os

#転送先アドレス(カンマ区切りで複数指定)
FORWARD_TO = os.environ['forward_to'].split(",")

#メール保存先バケット名
S3_BUCKET = "s3-bucket-name"

#転送メールの送信元ドメイン名
DOMAIN_NAME = "hogehoge.jp"

s3  = boto3.client('s3')
ses = boto3.client('ses', region_name="us-east-1")

def lambda_handler(event, context):
    #本来の送信者
    MAIL_SOURCE = event['Records'][0]['ses']['mail']['source']

    #転送メールの送信者
    MAIL_FROM = MAIL_SOURCE.replace('@','=') + "@" + DOMAIN_NAME

    #メール保存先のフォルダ名
    S3_OBJECT_PREFIX = event['Records'][0]['ses']['receipt']['recipients'][0].split("@")[0] + "/"

    #S3上のメールファイル
    s3_key = S3_OBJECT_PREFIX + event['Records'][0]['ses']['mail']['messageId']

    #メールファイル取得
    try:
        response = s3.get_object(
            Bucket = S3_BUCKET,
            Key    = s3_key
        )
    except Exception as e:
        raise e

    #メールヘッダの書き換え
    try:
        replaced_message = response['Body'].read().decode('utf-8')
        replaced_message = re.sub("\nTo: .+?\n", "\nTo: %s\n" % ", ".join(FORWARD_TO), replaced_message,1)
        replaced_message = re.sub("\nFrom: .+?\n", "\nFrom: %s\n" % MAIL_FROM, replaced_message,1)
        replaced_message = re.sub("^Return-Path: .+?\n", "Return-Path: %s\n" % MAIL_FROM, replaced_message,1)
    except Exception as e:
        raise e

    #メール送信
    try:
        response = ses.send_raw_email(
            Source = MAIL_FROM,
            Destinations= FORWARD_TO ,
            RawMessage={
                'Data': replaced_message
            }
        )
    except Exception as e:
        raise e

send_raw_emailを使い、受信したメールのBODYをそのまま送信するのであれば比較的シンプルに実装できそうだ。ただし、一般的なメール転送でよくあるような、メール本文の冒頭に元メールのヘッダ情報を追記するようなことは行っていないため、元メールの送信者がわからないという問題がある。メール本文を編集しようとするとsend_emailを使えば良さそうだが、文字コードやHTMLメール、添付ファイルの考慮など、前述のとおりディープな世界へ足を踏み入れることになり、大変。なのでここでは諦めてシンプルに。

FORWARD_TO = os.environ['forward_to'].split(",")
転送先のメールアドレスはLambdaの環境変数を使って設定する。カンマ区切りの文字列で複数を指定することも可能にしている。

MAIL_FROM = MAIL_SOURCE.replace('@','=') + "@" + domain_name
ここでひと工夫。元メールの差出人アドレスを転送メールの差出人アドレス内に埋め込む。

S3_OBJECT_PREFIX = event['Records'][0]['ses']['receipt']['recipients'][0].split("@")[0] + "/"
SESがS3へ出力する際のPrefixに合わせていれば何でも良いが、ここでは受信アカウントをPrefixとしている。

案2 Amazon WorkMailを使う

月額4USDのコストが許容できるのであればSESの受け口としてWorkMailが使える。WorkMailには転送機能がある。

WorkMailのセットアップ方法は以下がわかりやすい。
AWS WorkMailを使ってみたら想像以上に便利だった

WorkMailでの転送設定は以下(英語の公式ドキュメント)を参考に。
How do I set up an email forwarding rule in Amazon WorkMail?

Lambdaで実現できるはずの機能を月額4USDで逃げるのはエンジニアとして負けた気がしてしまうが・・・スピードと確実性を優先するならアリかと。

続きを読む

AWS CloudFormationを使ってAWS Fargateの環境を作成してみる

本記事は個人の意見であり、所属する組織の見解とは関係ありません。

こちらはAWS Fargate Advent Calendar 2017の6日目の記事です。
AWS Fargateが発表されて、一週間ぐらい経ちました。新しいサービス、機能を色々試してみるのは楽しいですよね!

今日は、Fargateを触ってみて、もう少し本格的に取り組んでみたいと感じた方向けにAWS CloudFormationを使ってAWS Fargateの環境を作成する流れについて確認してみたいと思います。

AWS CloudFormationとは

Cloudformationでは、AWSリソースの環境構築を設定テンプレートを元に自動化する事ができます。ECSで利用する場合、TaskdefinisionやServiceの設定なども記述する事ができます。Containerのデプロイをより簡単に行える様になり各種自動化を行いやすくなるメリットもあります。

今回はFargateのAdvent Calendarへの投稿ですので、詳細については、次のWebinerの資料を確認してみてください。

CloudFormationテンプレート作成

作成方針

Cloudformationのテンプレートは記載の自由度が高く、色々な記述の仕方ができるのですが、今回は分かりやすさを重視して次の様な構成で分割したテンプレートを作成してみました。

  • VPC作成用テンプレート

    • Fargate用のVPCを作成し、VPCの設定を行うテンプレート
    • PublicSubnetやPrivateSubnet、ルートテーブルなどを作成していきます。
  • SecurityGroup作成用テンプレート

    • TaskやALBで利用するSecurityGroupを作成します。
  • ECSクラスターを作成するテンプレート

  • ELBを設定するテンプレート

  • TaskDefinitionテンプレート

    • ECS上で起動するContainerに関する設定を行います。
  • Serviceテンプレート

分割の仕方も様々ですので、各自のユースケースにあわせて、色々と試してみてください。個人的には、ライフサイクルが異なるリソースは別テンプレートにするが好きです。逆に、開発環境やデモ環境を素早く立ち上げたい場合は1つのテンプレートの中に全て記載してしまうのもいいですよね。

VPC作成用テンプレート

テンプレートの一部は次の様な形になります。(折りたたんでいます)

Resources:
    VPC:
        Type: AWS::EC2::VPC
        Properties:
            CidrBlock: !Ref VpcCIDR
            Tags:
                - Key: Name
                  Value: !Ref EnvironmentName

    InternetGateway:
        Type: AWS::EC2::InternetGateway
        Properties:
            Tags:
                - Key: Name
                  Value: !Ref EnvironmentName


  <省略>


Outputs:

    VPC:
        Description: A reference to the created VPC
        Value: !Ref VPC
        Export:
          Name: !Sub ${EnvironmentName}-VPC

ポイントは、作成したリソースに関する情報を別リソースからもアクセスできる様に
OutpusセクションでExport属性をつけている事です。Export属性で定義しているNameを利用して、
別テンプレートからも対象リソースに対する参照を行う事ができます。

SecurityGroup作成用テンプレート

ALB用、Container用のSecurityGroupを作成し、必要なPortを許可しています。

こちらも長いので、折りたたんでいます。

Description: >
    This template deploys a security-groups.

Parameters:
  EnvironmentName:
    Description: An environment name that will be prefixed to resource names
    Type: String
    Default: advent-calendar-2017

Resources:
  LoadBalancerSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      VpcId: !ImportValue advent-calendar-2017-VPC
      GroupDescription: SecurityGroup for ALB
      SecurityGroupIngress:
        - CidrIp: 0.0.0.0/0
          IpProtocol: tcp
          FromPort: 80
          ToPort: 80
      Tags:
        - Key: Name
          Value: !Sub ${EnvironmentName}-LoadBalancers


  ContainerSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      VpcId: !ImportValue advent-calendar-2017-VPC
      GroupDescription: Security Group for Task
      SecurityGroupIngress:
        -
          SourceSecurityGroupId: !Ref LoadBalancerSecurityGroup
          IpProtocol: -1
        -
          CidrIp: 0.0.0.0/0
          IpProtocol: tcp
          FromPort: 80
          ToPort: 80

      Tags:
        - Key: Name
          Value: !Sub ${EnvironmentName}-ContainerSecurityGroup

Outputs:
  LoadBalancerSecurityGroup:
    Description: A reference to the security group for load balancers
    Value: !Ref LoadBalancerSecurityGroup
    Export:
      Name: !Sub ${EnvironmentName}-LoadBalancerSecurityGroup

  ContainerSecurityGroup:
    Description: A reference to the security group for EC2 hosts
    Value: !Ref ContainerSecurityGroup
    Export:
      Name: !Sub ${EnvironmentName}-ContainerSecurityGroup

ECSクラスタ-を作成するテンプレート

非常にシンプルです。ただ、クラスタを定義して名前をつけるだけ。

Description: >
    This sample template deploys a ECS Cluster

Parameters:
  EnvironmentName:
    Description: An environment name that will be prefixed to resource names
    Type: String
    Default: advent-calendar-2017

Resources:
  ECSCluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterName: !Sub ${EnvironmentName}-cluster

Outputs:
  ECSCluster:
    Description: A referenc
    Value: !Ref ECSCluster
    Export:
      Name: !Sub ${EnvironmentName}-Cluster

ELBを設定するテンプレート

ALB、Listener、Targetgroupを作成しています。

Description: >
    This template deploys an Application Load Balancer.


Parameters:
  EnvironmentName:
    Description: An environment name that will be prefixed to resource names
    Type: String
    Default: advent-calendar-2017


Resources:
  LoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: !Ref EnvironmentName
      Subnets:
        - !ImportValue advent-calendar-2017-PublicSubnet1
        - !ImportValue advent-calendar-2017-PublicSubnet2
      SecurityGroups:
        - !ImportValue advent-calendar-2017-LoadBalancerSecurityGroup
      Tags:
        - Key: Name
          Value: !Ref EnvironmentName
      Scheme: internet-facing

  LoadBalancerListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref LoadBalancer
      Port: 80
      Protocol: HTTP
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref DefaultTargetGroup

  DefaultTargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: !Sub ${EnvironmentName}-targetgroup
      VpcId: !ImportValue advent-calendar-2017-VPC
      Port: 80
      Protocol: HTTP
      TargetType: ip

Outputs:

  LoadBalancer:
    Description: A reference to the Application Load Balancer
    Value: !Ref LoadBalancer
    Export:
      Name: !Sub ${EnvironmentName}-Loadbalancer

  LoadBalancerUrl:
    Description: The URL of the ALB
    Value: !GetAtt LoadBalancer.DNSName

  Listener:
    Description: A reference to a port 80 listener
    Value: !Ref LoadBalancerListener
    Export:
      Name: !Sub ${EnvironmentName}-Listener

  DefaultTargetGroup:
    Value: !Ref DefaultTargetGroup
    Export:
      Name: !Sub ${EnvironmentName}-DefaultTargetGroup

TaskDefinition設定

ようやくFargateに関連する設定が出てきました。ここでは、RequiresCompatibilities属性にFARGATEを指定し、
NetworkMode属性にawsvpcを指定しています。また、CPU、メモリの設定はContainerDefinitionsの外側で設定します。
Container Definitionsにおけるmemory/cpuの指定はオプションです。加えて、各Taskがログを出力するためのCloudwatch Logsの設定もここで行なっています。

Description: >
    This sample deploys a task

Parameters:
  EnvironmentName:
    Description: An environment name that will be prefixed to resource names
    Type: String
    Default: advent-calendar-2017

Resources:
  LogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub '/ecs/logs/${EnvironmentName}-groups'

  ECSTask:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Cpu: 256
      ExecutionRoleArn: arn:aws:iam::XXXXXXXXXXXX:role/ecsTaskExecutionRole
      Family: !Sub ${EnvironmentName}-task
      Memory: 512
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      ContainerDefinitions:
        -
          Name: nginx
          Image: nginx:latest
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: us-east-1
              awslogs-stream-prefix: ecs
          MemoryReservation: 512
          PortMappings:
            -
              HostPort: 80
              Protocol: tcp
              ContainerPort: 80

Outputs:
  LogGroup:
      Description: A reference to LogGroup
      Value: !Ref LogGroup

  ECSTask:
    Description: A reference to Task
    Value: !Ref ECSTask

Service設定

ここではFargate上でTaskを起動させるために、LaunchType属性にFARGATEを指定しています。ここでTaskNameに指定しているXXの数字はTaskのRevisionに該当します。Taskの更新とともにここの数字を変える必要があるという点がポイントです。

Description: >
    This sample deploys a service

Parameters:
  EnvironmentName:
    Description: An environment name that will be prefixed to resource names
    Type: String
    Default: advent-calendar-2017

  TaskName:
    Description: A task name
    Type: String
    Default: advent-calendar-2017-task:XX

Resources:
  Service:
    Type: AWS::ECS::Service
    Properties:
      Cluster: !ImportValue advent-calendar-2017-Cluster
      DesiredCount: 2
      LaunchType: FARGATE
      LoadBalancers:
        -
          TargetGroupArn: !ImportValue advent-calendar-2017-DefaultTargetGroup
          ContainerPort: 80
          ContainerName: nginx
      NetworkConfiguration:
        AwsvpcConfiguration:
          SecurityGroups:
            -
              !ImportValue advent-calendar-2017-ContainerSecurityGroup
          Subnets:
            -
              !ImportValue advent-calendar-2017-PrivateSubnet1
            -
              !ImportValue advent-calendar-2017-PrivateSubnet2
      ServiceName: !Sub ${EnvironmentName}-service
      TaskDefinition: !Ref TaskName
Outputs:
  Service:
      Description: A reference to the service
      Value: !Ref Service

Cloudformation Stackを作成する

これで、Fargate環境の作成準備が整いました。ここからは順番にStackを作成していきます。

$ aws cloudformation create-stack --stack-name advent-calendar-2017-vpc 
--template-body file://Fargate-vpc.yml 
--region us-east-1

$ aws cloudformation create-stack --stack-name advent-calendar-2017-security-group 
--template-body file://Fargate-security-groups.yaml 
--region us-east-1

$ aws cloudformation create-stack --stack-name advent-calendar-2017-load-balancer 
--template-body file://Fargate-load-balancers.yaml 
--region us-east-1

$ aws cloudformation create-stack --stack-name advent-calendar-2017-cluster 
--template-body file://Fargate-cluster.yml 
--region us-east-1


$ aws cloudformation create-stack --stack-name advent-calendar-2017-task 
--template-body file://Fargate-taskdefinition.yml 
--region us-east-1

$ aws cloudformation create-stack --stack-name advent-calendar-2017-service 
--template-body file://Fargate-service.yml 
--region us-east-1

作成した環境を確認する

Cloudformationでの環境構築が終わりました。正しく構築できているか、ALB経由でアクセスして確認してみてください。
作成したALBのFQDNは、マネージメントコンソール上のEC2の画面>ロードバランサにアクセスして確認できます。
それ以外にも今回の例では、CLIでの次の様なコマンドで確認する事ができます。(少し無理やりですが。。。)

$ aws cloudformation describe-stacks --stack-name advent-calendar-2017-load-balancer  
--region us-east-1 | jq '.Stacks[].Outputs[] | select(.OutputKey == "LoadBalancerUrl")'

{
  "Description": "The URL of the ALB",
  "OutputKey": "LoadBalancerUrl",
  "OutputValue": "advent-calendar-2017-844241308.us-east-1.elb.amazonaws.com"
}


####awscli単独でやるなら、次の様にも書く事ができます。

aws cloudformation describe-stacks --stack-name advent-calendar-2017-load-balancer  
--region us-east-1 
--query 'Stacks[].Outputs[?OutputKey == `LoadBalancerUrl`].OutputValue'

ECSクラスター

次のコマンドで存在が確認できます。

$ aws ecs list-clusters --region us-east-1
{
    "clusterArns": [
        "arn:aws:ecs:us-east-1:925496135215:cluster/advent-calendar-2017-cluster"
    ]
}

サービスの状態

作成したサービスの状態は次の様なコマンドで確認できます。

aws ecs describe-services --services <service name> 
--cluster <cluster name> --region us-east-1

例えば、デプロイしているサービスの状況を確認する際には以下の様なコマンドで状態を取得可能です。
次のコマンド結果には、runningCountが2であり、desiredCountの設定通りにTaskが起動している事が確認できます。


$ aws ecs describe-services --services advent-calendar-2017-service 
--cluster advent-calendar-2017-cluster 
--region us-east-1 | jq .[][].deployments
[
  {
    "status": "PRIMARY",
    "networkConfiguration": {
      "awsvpcConfiguration": {
        "subnets": [
          "subnet-2541e678",
          "subnet-9297e0f6"
        ],
        "securityGroups": [
          "sg-326f1047"
        ]
      }
    },
    "pendingCount": 0,
    "createdAt": 1512499161.953,
    "desiredCount": 2,
    "taskDefinition": "arn:aws:ecs:us-east-1:XXXXXXXXXXXX:task-definition/advent-calendar-2017-task:3",
    "updatedAt": 1512500281.269,
    "id": "ecs-svc/9223370524355613851",
    "runningCount": 2
  }
]

デプロイしたServiceを更新する

Cloudformationを利用して作成していますので、更新もCloudformation経由で行います。

テンプレートを更新する。

今回はDesiredCountを修正してみました。

$ diff Fargate-service-update.yml Fargate-service.yml
20c20
<       DesiredCount: 4
---
>       DesiredCount: 2

Stackを更新する

次の様なコマンドでStackの更新が可能です。

$ aws cloudformation update-stack --stack-name advent-calendar-2017-service 
--template-body file://Fargate-service-update.yml 
--region us-east-1

しばらく待った後に再びServiceの状態を確認するとDsierdCount通りにTaskの数が増えている事が確認できます。

$ aws ecs describe-services --services advent-calendar-2017-service 
--cluster advent-calendar-2017-cluster 
--region us-east-1 | jq .[][].deployments
[
  {
    "status": "PRIMARY",
    "networkConfiguration": {
      "awsvpcConfiguration": {
        "subnets": [
          "subnet-2541e678",
          "subnet-9297e0f6"
        ],
        "securityGroups": [
          "sg-326f1047"
        ]
      }
    },
    "pendingCount": 0,
    "createdAt": 1512499161.953,
    "desiredCount": 4,
    "taskDefinition": "arn:aws:ecs:us-east-1:925496135215:task-definition/advent-calendar-2017-task:3",
    "updatedAt": 1512538215.582,
    "id": "ecs-svc/9223370524355613851",
    "runningCount": 4
  }
]

Taskをアップデートする。

テンプレートを更新する。

Taskが利用するメモリの容量を修正してみました。

$ diff Fargate-taskdefinition-update.yml Fargate-taskdefinition.yml 
25c25
<       Memory: 1024
---
>       Memory: 512

Stackを更新する

次の様なコマンドでTask用のStackの更新をします。

$ aws cloudformation update-stack --stack-name advent-calendar-2017-task 
--template-body file://Fargate-taskdefinition-update.yml 
--region us-east-1

TaskのRevisionが変化していますので、Serviceでも新しいRevisionを利用する様に、テンプレートを修正して、Service用のStackを更新します。

$ aws cloudformation update-stack --stack-name advent-calendar-2017-service 
--template-body file://Fargate-service.yml 
--region us-east-1

再度、サービスの状態を確認して、起動しているTaskが更新されている事を確認してみてください。

まとめ

Cloudformationを利用したECS,Fargateの操作はいかがだったでしょうか。今回の記事を書く為に、新規でCloudformationテンプレートを作成したのですが、これまでのECSで利用していたテンプレートとの違いは僅かでした。FargateをきっかけにECSに興味を持って頂けた方の参考になればうれしいです。

続きを読む

CloudFormationで、ECSのCI/CD環境を構築した際のハマりどころ 〜CodePipeline,CodeBuild,KMSも添えて〜

Classiアドベントカレンダー4日目です。
本日は、ECSを利用して、AWS上でAWSどっぷりのCI/CD環境を準備したときのお話になります。

今年のre:InventでEKSとFargateがリリースされましたが、東京リージョンに来てなかったり、プレビュー段階だったりで、まだしばらくは参考になる部分はありそうかなと^^;

1.背景

などで、AWS公式でもECS環境下のCloudFormation(以下、CFn)を使ったデプロイ方法が紹介されています。
とはいえ、現実の要件でCFnで実装しようとすると、デフォルト設定だと失敗したり、ドキュメントだけだと、GUIで設定できる部分がCFnでの書き方がわからかったりして、いくつかハマった内容があったので、3種類ぐらいの特徴を抜粋して書いてみようと思います。

2.TL;DR

ECSを使うなら、

  • ALBとECSの動的ポート機能を組み合わせる
  • IAM Role,KMS,SSMパラメータストアを組み合わせる
  • CodePipelineで複数リポジトリからのコード取得を行う

これらの機能を全部CFnでやろうとすると、一部aws-cliなどを使う必要がありますが、
ひとまずDevとOpsでうまく権限を分担したCI/CD環境を構築できるのではないかなと思います。

3.特徴解説

3-1. ALBとECSの動的ポート機能の組み合わせ

qiita_ecs_port.png

EC2へ割り当てるSecurityGroupは、ECSの動的ポート機能を利用するため、インバウンドのTCPポートを開放しておきます。

securitygroup.yml
ALBSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
        VpcId: !Ref VpcId
       GroupName: sample
       GroupDescription: "ALB Serurity Group"
       SecurityGroupIngress:
            -
                CidrIp: 0.0.0.0/0
                IpProtocol: tcp
                FromPort: 443
                ToPort: 443
EC2SecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
        VpcId: !Ref VpcId
       GroupName: sample
       GroupDescription: "EC2 Serurity Group"
       SecurityGroupIngress:
            -
                SourceSecurityGroupId: !Ref ALBSecurityGroup
                IpProtocol: tcp
                FromPort: 0
                ToPort: 65535

ECSの動的ポートを有効にするため、PortMappingsの設定でホストのポートを0に設定します。

ecs.yml
ECSTask:
    Type: "AWS::ECS::TaskDefinition"
    Properties:
        Family: sample
        NetworkMode: bridge
        ContainerDefinitions:
            -
                Name: sample
                Image: !Sub "${AWS::AccountId}.dkr.ecr.${AWS::Region}.amazonaws.com/${ECRName}:${ImageTag}"
                Cpu: 2
                Memory: 128
                PortMappings:
                    -
                        ContainerPort: 80
                        HostPort: 0
                Essential: true
                Ulimits:
                    -
                        Name: nofile
                        SoftLimit: 65535
                        HardLimit: 65535
                Environment:
                    -
                        Name: TZ
                        Value: Asia/Tokyo
                LogConfiguration:
                    LogDriver: awslogs
                    Options:
                        awslogs-group: sample
                        awslogs-region: !Sub ${AWS::Region}
                        awslogs-stream-prefix: !Ref ImageTag
    Service:
        Type: "AWS::ECS::Service"
        Properties:
            ServiceName: sample
            Cluster: !Ref ECSCluster
            DesiredCount: 1
            TaskDefinition: !Ref ECSTask
            Role: !Ref ECSServiceRole
            PlacementStrategies:
                -
                    Type: spread
                    Field: instanceId
            LoadBalancers:
                -
                    ContainerName: sample
                    ContainerPort: 80
                    TargetGroupArn: !Ref ALBTargetGroup

注意点

複数のEC2でECSを運用するのであれば、PlacementStrategiesの設定を行っておかないと、random配置ECSのタスクが一つのホストだけに偏ってしまったりすることがあります。

3-2. DevとOpsで別gitリポジトリを運用しつつ、CodePipelineのデプロイフェーズでCFnのChangeSetを使う

qiita_codepipeline.png

デプロイにCFnを利用することで、デプロイの実行記録の管理やCFnで記載された部分のインフラ部分のテストを行いつつ、デプロイをすることが可能になります。
また、Sourceフェーズで、CFnの内容やEC2のASGやAMI設定の管理を行うOps管轄リポジトリと、Dockerコンテナ化するアプリロジックが含まれているDev管轄リポジトリを分割することで、
運用フェーズに入ったときにDevとOpsで独立して、デプロイを行うことができます。

codepipeline.yml
CodePipeline:
    Type: "AWS::CodePipeline::Pipeline"
    Properties:
        Name: sample
        ArtifactStore:
            Type: S3
            Location: sample
        RoleArn: !Ref BuildRole
        Stages:
            -
                Name: Source
                Actions:
                    -
                        Name: AppSource
                        RunOrder: 1
                        ActionTypeId:
                            Category: Source
                            Owner: ThirdParty
                            Version: 1
                            Provider: GitHub
                        Configuration:
                            Owner: !Ref GithubOwner
                            Repo: !Ref GithubAppRepo
                            Branch: !Ref GithubAppBranch
                            OAuthToken: !Ref GithubToken
                        OutputArtifacts:
                            - Name: AppSource
                    -
                        Name: InfraSource
                        RunOrder: 1
                        ActionTypeId:
                            Category: Source
                            Owner: ThirdParty
                            Version: 1
                            Provider: GitHub
                        Configuration:
                            Owner: !Ref GithubOwner
                            Repo: !Ref GithubInfraRepo
                            Branch: !Ref GithubInfraBranch
                            OAuthToken: !Ref GithubToken
                        OutputArtifacts:
                            - Name: InfraSource
            -
                Name: Build
                Actions:
                    -
                        Name: CodeBuild
                        RunOrder: 1
                        InputArtifacts:
                            - Name: AppSource
                        ActionTypeId:
                            Category: Build
                            Owner: AWS
                            Version: 1
                            Provider: CodeBuild
                        Configuration:
                            ProjectName: !Ref CodeBuild
                        OutputArtifacts:
                            - Name: Build
            -
                Name: CreateChangeSet
                Actions:
                    -
                        Name: CreateChangeSet
                        RunOrder: 1
                        InputArtifacts:
                            - Name: InfraSource
                            - Name: Build
                        ActionTypeId:
                            Category: Deploy
                            Owner: AWS
                            Version: 1
                            Provider: CloudFormation
                        Configuration:
                            ChangeSetName: Deploy
                            ActionMode: CHANGE_SET_REPLACE
                            StackName: !Sub ${AWS::StackName}
                            Capabilities: CAPABILITY_NAMED_IAM
                            TemplatePath: !Sub "Source::sample.yml"
                            ChangeSetName: !Ref CFnChangeSetName
                            RoleArn: !Ref BuildRole
                            ParameterOverrides: !Sub |
                                {
                                    "ImageTag": { "Fn::GetParam" : [ "Build", "build.json", "tag" ] },
                                    "AppName": "${AppName}",
                                    "OwnerName": "${OwnerName}",
                                    "RoleName": "${RoleName}",
                                    "StageName": "${StageName}",
                                    "VpcId": "${VpcId}"
                                }
            -
                Name: Deploy
                Actions:
                    -
                        Name: Deploy
                        ActionTypeId:
                            Category: Deploy
                            Owner: AWS
                            Version: 1
                            Provider: CloudFormation
                        Configuration:
                            ActionMode: CHANGE_SET_EXECUTE
                            ChangeSetName: !Ref CFnChangeSetName
                            RoleArn: !Ref BuildRole
                            StackName: !Sub ${AWS::StackName}

注意点

  • CodePipelineのキックは、PRがマージされたタイミングなので、(一応、CodePipelineにはTestフェーズもあるが)マージ前のテストなどはCircleCIとかに任せた方がよいかも
  • ParameterOverridesで上書きするパラメータは、CFnのParametersに設定している項目に応じて設定する
  • Sourceフェーズで持ってこれるリポジトリは2つまで。コンテナビルドに持ってくるのがもっとある場合、CodeBuild内でこちらの記事のように、githubから引っ張ってきて、ビルドするなどの対応が必要になりそう

3-3. CodeBuildでDockerイメージを作る際、KMSとSSMパラメータストアを利用する

qiita_codebuild.png

このあたりはAWSの恩恵をフルに受けている部分かなと。
RDSのパスワードや秘密鍵など、gitリポジトリ内で管理したくない情報は、SSMパラメータストアを使って、Dockerイメージを作成するときに環境変数を埋め込みます。

codebuild.yml
CodeBuild:
    Type: AWS::CodeBuild::Project
    Properties:
        Name: sample
        Source:
            Type: CODEPIPELINE
        ServiceRole: !Ref BuildRole
        Artifacts:
            Type: CODEPIPELINE
        Environment:
            Type: LINUX_CONTAINER
            ComputeType: BUILD_GENERAL1_SMALL
            Image: "aws/codebuild/docker:1.12.1"
            EnvironmentVariables:
                -
                    Name: AWS_DEFAULT_REGION
                    Value: !Sub ${AWS::Region}
                -
                    Name: AWS_ACCOUNT_ID
                    Value: !Sub ${AWS::AccountId}
                -
                    Name: IMAGE_REPO_NAME
                    Value: !Ref ECRRepoName

docker buildするときに、--build-argに秘匿情報として環境変数を引き渡し、できあがったイメージをECRにpushする。

buildspec.yml
version: 0.2

phases:
    pre_build:
        commands:
            - $(aws ecr get-login --region $AWS_DEFAULT_REGION)
            - IMAGE_TAG="${CODEBUILD_RESOLVED_SOURCE_VERSION}"
            - DB_PASSWORD=$(aws ssm get-parameters --names rds_pass --with-decryption --query "Parameters[0].Value" --output text)
    build:
        commands:
            - docker build --build-arg DB_PASSWORD="${DB_PASSWORD}" -t "${IMAGE_REPO_NAME}:${IMAGE_TAG}" .
            - docker tag "${IMAGE_REPO_NAME}:${IMAGE_TAG}" "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${IMAGE_REPO_NAME}:${IMAGE_TAG}"
    post_build:
        commands:
            - docker push "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${IMAGE_REPO_NAME}:${IMAGE_TAG}"
            - printf '{"tag":"%s"}' "${IMAGE_TAG}" > build.json
artifacts:
    files:
        - build.json
    discard-paths: yes
(snip)
ARG DB_PASSWORD
ENV DB_PASSWORD=${DB_PASSWORD}
(snip)

実運用する際は、IAM Roleを使う権限も意識して、KMSのKeyを利用するIAM UserやIAM Roleを設定する。

kms.yml
KMSKey:
    Type: "AWS::KMS::Key"
    Properties:
        Description: sample-key
        KeyPolicy:
            Version: "2012-10-17"
            Id: "key-default-1"
            Statement:
                -
                    Sid: "Allow use of the key"
                    Effect: "Allow"
                    Principal:
                        AWS: !GetAtt BuildRole.Arn
                    Action:
                        - "kms:DescribeKey"
                        - "kms:Decrypt"
                    Resource: "*"

注意点

  • SSMパラメータにおける、SecureString型の値登録
    3-3.でSSMパラメータストアで暗号化する際、SecureString型はCFnに対応していない。
    そのため、aws-cliで設定することにした。TerraformはSecureString型に対応しているので、CFn側でも対応して欲しいところ…
$ aws ssm put-parameter --name rds-pass --value PASSWORD --type SecureString --key-id hogehoge

4. その他の雑多なハマりどころ

4-1. ECSのAMIのデフォルト設定

  • EBSのストレージタイプのデフォルトがHDD
    LaunchConfigurationのBlockDeviceMappingsで、gp2を明示的に指定してあげる。
  • WillReplace用のシグナルを送るcfn-signalが未インストール
    UserDataの中で記載しておく。シグナルを送るタイミングは、どこまでAMIに手を入れるかによって変更する。
LaunchConfig:
    Type: "AWS::AutoScaling::LaunchConfiguration"
    Properties:
        AssociatePublicIpAddress: true
        KeyName: sample
        IamInstanceProfile: sample
        ImageId: ami-e4657283
        SecurityGroups:
            - !Ref SecurityGroup
        InstanceType: t2.micro
        BlockDeviceMappings:
            -
                DeviceName: "/dev/xvda"
                Ebs:
                    VolumeType: gp2
                    VolumeSize: 30
        UserData:
            Fn::Base64: !Sub |
                #!/bin/bash
                echo ECS_CLUSTER=${ECSClusterName} >> /etc/ecs/ecs.config
                sudo yum install -y aws-cfn-bootstrap
                sleep 60
                /opt/aws/bin/cfn-signal -e $? --stack ${AWS::StackName} --resource AutoScalingGroup --region ${AWS::Region}
AutoScalingGroup:
    Type: "AWS::AutoScaling::AutoScalingGroup"
    Properties:
        LaunchConfigurationName: sample
        DesiredCapacity: 2
        MaxSize: 3
        MinSize: 2
        VPCZoneIdentifier:
            - !Ref PublicSubnet1
            - !Ref PublicSubnet2
    CreationPolicy:
        ResourceSignal:
            Count: 1
            Timeout: PT5M
    UpdatePolicy:
        AutoScalingReplacingUpdate:
            WillReplace: true

5.まとめ

もう少しきれいな書き方がありそうだけど、実運用でよくある要件の参考程度になれば幸いです。
EC2のASGまわりの設定は、従来のECSだとこのような形で大分インフラ側を意識しないといけない構成です。今後、re:Inventで発表されたEKSやFargateなどとも比べながら、本環境をアップデートしていければよいなと思います。

続きを読む

API Gateway で URL クエリパラメータの扱いで混乱した

gumi TS&D という部署のクラウドエンジニア担当です.
(12/1 当日に Calendar 作って, 当日夕方書きました)

ご存知の通り簡単な API であれば API Gateway は最適です.
構築も AWS マネージメントコンソールからドキュメント見ながらぽちぽちしていくだけなのでとても楽かと思います.

しかし, ぽちぽち構築は自動化の妨げになるので, クラウドエンジニアとしてはコード化しないといけません.

今回簡単な API を API Gateway で作る機会があり, クエリパラメータの扱いで混乱したので紹介します.

(ちなみに作成した API)

Untitled.png

今回作った API は, POST したファイルを S3 の指定したパスに保存するものです.

混乱箇所

CloudFormation の Template(抜粋) は以下のようになります.

ApiGateway:
    Type: "AWS::ApiGateway::Method"
    Properties:
        RequestParameters:"method.request.querystring.objectPath": true
        Integration:
            RequestParameters:
                "integration.request.path.objectPath":  "method.request.querystring.objectPath"
        Uri: !Sub "arn:aws:apigateway:ap-northeast-1:s3:path/sample_bucket/{objectPath}"
        ...
  • 「RequestParameters」に受け付ける URL クエリパラメータの設定
  • 「Integration.RequestParameters」で変数にマッピング

この二つの設定が必要.

私は「RequestParameters」で混乱した.

Properties 直下の「RequestParameters」は「メソッドリクエスト」の URL パスパラメータの設定.

スクリーンショット 2017-12-01 15.09.56.png

は「統合リクエスト」の URL パスパラメータの設定.

スクリーンショット 2017-12-01 15.54.19.png

また, 「Integration.RequestParameters」に関しては, マネージメントコンソールと同じようにパラメータ名だけの指定ではダメ.

integration.request.<location>.<name>

の形式で指定しなければならない.

続きを読む