serverless frameworkを使って本格的なAPIサーバーを構築(魅力編)

serverless frameworkを使って本格的なAPIサーバーを構築(魅力編)

目次

  • serverless frameworkを使って本格的なAPIサーバーを構築(魅力編)← 今ここ
  • serverless frameworkを使って本格的なAPIサーバーを構築(ハンズオン編)
  • serverless frameworkを使って本格的なAPIサーバーを構築(Express編)
  • serverless frameworkを使って本格的なAPIサーバーを構築(MVC編)

framework_repo.png

こんにちは!某会社のインフラエンジニアをしておりますが、最近は サーバーレス という言葉が流行ってますね。
今回は、serverless frameworkで 「 lambda + APIGateway + DynamoDB 」 の構成でいくつかサービスを作ったので、利点などをまとめていけたらと思います。

サーバーレスとserverless framework

まずサーバーレスとは

サーバーの管理が不要になる

  • サーバーレスアーキテクチャでは、管理するサーバーがないため、サーバー管理から開放され、その分管理コストがかからなくなります。物理的なサーバーの管理はクラウド事業者に一任できるので、故障やパッチ適用の心配をする必要はありません。

また、一般にコード実行サービスは高い可用性を持っているため、オンプレミスサーバーよりも障害耐性が高くなるでしょう。

アプリ開発において便利

  • APIサーバーとして、ハッカソン用に作ったアプリの運用など、ほとんどアクセスがないようなAPIサーバーをずっと放置しておくのは大変お金がかかります。

従来の冗長化構成に比べ安価

  • EC2 + RDS環境を構築したときに、ある程度の規模であれば安価に済ますことができます。

サーバーの脆弱性を突いた攻撃が効かない

  • セキュリティ被害の多くはサーバーOSやミドルウェアの脆弱性を突いた攻撃ですが、公開ネットワーク上にサーバーが無いので、一般的なサーバーの脆弱性を突く攻撃が効きません。

  • Amazonのプラットフォームに脆弱性が無いとは言えませんが、プラットフォーム仕様が公開されていないので脆弱性を探すことが難しく、一般的なサーバーOSより脆弱性被害の可能性は少ないと考えられます。

サーバーが落ちることがない

  • サーバーが無いのでサーバーダウンの概念がありません。

  • 厳密にはAmazonがダウンすれば共倒れしますが、どのプラットフォームもサービス本体がダウンすれば同じことが言えるので、世界的インフラのAWSと、自社サーバーや一般企業のレンタルサーバーのどちらを信用するか、という話になります。

放置できる

  • セキュリティパッチや、サーバー自体の管理が発生しないためある程度放置できます。

インフラエンジニアがいなくてもスケールする

  • lambdaは最大で3000も同時に実行できます。
  • dynamodbは、キャパシティユニットとオートスケールの設定がかんたんにできます。
  • S3で静的コンテンツを配信したとしても障害確率はかなり低いです。

とにかく安い!

  • サーバーは初期導入コストが高く、必要になるサーバースペックも事前に見積もっておかなければいけません。物理障害時のパーツ交換費用なども必要です。
    クラウドサーバーにしても、起動している時間は料金がかかるため、閑散時などは100%コストを活用できているとはいえません。

  • 一方、コード実行サービスであれば、実際にコードを実行した時間に応じて課金されるため、ムダなコストが発生しません。初期導入コストもかからないため、システムの規模に合わせて柔軟にスケールできます。

保守メンテが楽

  • セキュリテイパッチやサーバー管理などのメンテが不要になります。

APIGateway+DynamoDB+Lambda手動構築の苦悩

image.png

  • serverless framework を使わずに「lambda + APIGateway + DynamoDB」を構築しようとすると、ほぼボタン操作で設定しないといけないです。
  • どのステージをデプロイするのか、設定した項目・手順を忘れた。もとに戻したい、引き継ぎを行いたいなどいろいろなケースが考えられます

再現性がない

  • これに尽きると思います。
  • 基本的にAPIGatewayの設定などはボタン操作です。もちろんgit管理などできない。
    ステージの作成、レスポンスコード、methodの作成など操作を間違えても元に戻すことが難しくなり、実装コスト、人件費、考えるだけで大変なことが多すぎます。(大変だった。)

よって保守性・メンテ・引き継ぎが難しくなっていました。

そこでserverless frameworkの出番です!!

image.png

  • 基本的にAWSコンソールでのボタン操作は不要!!
  • serverless.ymlですべて管理・設定ができる => 設定をgit管理できる

    • aws cloudformationの形式と似ているため、最悪それを真似できます(cloudformationよりかんたん!)
  • コマンド1発でデプロイとロールバックが可能
  • 公式リポジトリにサンプルが沢山!!(こまりません)
  • プラグインもたくさん(ぶっちゃけ使わなくても高機能!!)
  • Expressと組み合わせると、超便利!!!!!(後日記事を書きます)

注意点など

  • iamのadmin権限が必要です。
  • Cloudfrontは設定できません。

次回

  • serverless frameworkを使って本格的なAPIサーバーを構築(ハンズオン編)(仮)

参考記事

続きを読む

メールにパスワード付きzipを添付して「パスワードは別途お送りいたします」とする慣習がめんどくさいのでなんとかした

あの慣習

メールにパスワード付きzipを添付して「パスワードは別途お送りいたします」とする慣習、ありますよね。
自分からはやらないけど、相手に合わせてやらざるを得なかったりしてめんどくさい。

ここでは、このやり方の是非は問題にしません。
どんなに是非を説いても、この慣習があるという状況は変わらないので。

そして、この慣習を無くすことも考えません。
そういうのは巨大な力を持った何かにおまかせします。

昔のエラい人は言いました。「長いものには巻かれろ」と。
ただし、巻かれ方は考えたほうがいいと思うのです。

スマートな巻かれ方を考える

巻かれるにあたって、解決したいことはただ一つ。めんどくさくないこと。
このためにWebシステム作って、ブラウザ開いてどうのこうのなんてやってると本末転倒です。
可能な限り、普通のメール送信に近い形で実現したい。

というわけで、あれこれ考えた末、一部の制約を許容しつつ、AmazonSESを使ってサーバーレスな感じで解決してみました。

仕様

  1. 普通にメールを書く(新規・返信・転送問わず)
  2. ファイルをzipで固めずにそのまま放り込む
  3. SES宛のメールアドレスをToに、実際にファイルを送りたい相手をReply-Toに設定する。
  4. システムを信じて送信ボタンを押す
  5. 自分と相手に、パスワード付きzipが添付されたメールとパスワードのお知らせメールが届く

ただし、以下の制約があります。個人的には許容範囲です。

  • 結果的に相手方には全員Toで届く。Ccはできない(自分はBcc)
  • zipファイルの名前は日時(yymmddHHMMSS.zip)になる(中身のファイル名はそのまま)

システム構成

flow_01.png

  1. SESに宛ててメールを送る
  2. メールデータがS3に保存される
  3. それをトリガーにしてLambdaが起動する
  4. Lambdaがメールの内容を解析してパスワードとzipファイルを生成する
  5. いい感じにメールを送る(念のため自分にもBccで送る)

実装

Lambda

真面目にpython書いたの初めてだけどこんな感じでいいのかな?
大体メールと文字コードとファイルとの戦いです。

# -*- coding: utf-8 -*-

import os
import sys
import string
import random
import json
import urllib.parse
import boto3
import re
import smtplib
import email
import base64
from email                import encoders
from email.header         import decode_header
from email.mime.base      import MIMEBase
from email.mime.multipart import MIMEMultipart
from email.mime.text      import MIMEText
from email.mime.image     import MIMEImage
from datetime             import datetime

sys.path.append(os.path.join(os.path.dirname(os.path.realpath(__file__)), 'vendored'))
import pyminizip

s3 = boto3.client('s3')

class MailParser(object):
    """
    メール解析クラス
    (参考) http://qiita.com/sayamada/items/a42d344fa343cd80cf86
    """

    def __init__(self, email_string):
        """
        初期化
        """
        self.email_message    = email.message_from_string(email_string)
        self.subject          = None
        self.from_address     = None
        self.reply_to_address = None
        self.body             = ""
        self.attach_file_list = []

        # emlの解釈
        self._parse()

    def get_attr_data(self):
        """
        メールデータの取得
        """
        attr = {
                "from":         self.from_address,
                "reply_to":     self.reply_to_address,
                "subject":      self.subject,
                "body":         self.body,
                "attach_files": self.attach_file_list
                }
        return attr


    def _parse(self):
        """
        メールファイルの解析
        """

        # メッセージヘッダ部分の解析
        self.subject          = self._get_decoded_header("Subject")
        self.from_address     = self._get_decoded_header("From")
        self.reply_to_address = self._get_decoded_header("Reply-To")

        # メールアドレスの文字列だけ抽出する
        from_list =  re.findall(r"<(.*@.*)>", self.from_address)
        if from_list:
            self.from_address = from_list[0]
        reply_to_list =  re.findall(r"<(.*@.*)>", self.reply_to_address)
        if reply_to_list:
            self.reply_to_address = ','.join(reply_to_list)

        # メッセージ本文部分の解析
        for part in self.email_message.walk():
            # ContentTypeがmultipartの場合は実際のコンテンツはさらに
            # 中のpartにあるので読み飛ばす
            if part.get_content_maintype() == 'multipart':
                continue
            # ファイル名の取得
            attach_fname = part.get_filename()
            # ファイル名がない場合は本文のはず
            if not attach_fname:
                charset = str(part.get_content_charset())
                if charset != None:
                    if charset == 'utf-8':
                        self.body += part.get_payload()
                    else:
                        self.body += part.get_payload(decode=True).decode(charset, errors="replace")
                else:
                    self.body += part.get_payload(decode=True)
            else:
                # ファイル名があるならそれは添付ファイルなので
                # データを取得する
                self.attach_file_list.append({
                    "name": attach_fname,
                    "data": part.get_payload(decode=True)
                })

    def _get_decoded_header(self, key_name):
        """
        ヘッダーオブジェクトからデコード済の結果を取得する
        """
        ret = ""

        # 該当項目がないkeyは空文字を戻す
        raw_obj = self.email_message.get(key_name)
        if raw_obj is None:
            return ""
        # デコードした結果をunicodeにする
        for fragment, encoding in decode_header(raw_obj):
            if not hasattr(fragment, "decode"):
                ret += fragment
                continue
            # encodeがなければとりあえずUTF-8でデコードする
            if encoding:
                ret += fragment.decode(encoding)
            else:
                ret += fragment.decode("UTF-8")
        return ret

class MailForwarder(object):

    def __init__(self, email_attr):
        """
        初期化
        """
        self.email_attr = email_attr
        self.encode     = 'utf-8'

    def send(self):
        """
        添付ファイルにパスワード付き圧縮を行い転送、さらにパスワード通知メールを送信
        """

        # パスワード生成
        password = self._generate_password()

        # zipデータ生成
        zip_name = datetime.now().strftime('%Y%m%d%H%M%S')
        zip_data = self._generate_zip(zip_name, password)

        # zipデータを送信
        self._forward_with_zip(zip_name, zip_data)

        # パスワードを送信
        self._send_password(zip_name, password)

    def _generate_password(self):
        """
        パスワード生成
        記号、英字、数字からそれぞれ4文字ずつ取ってシャッフル
        """
        password_chars = ''.join(random.sample(string.punctuation, 4)) + 
                         ''.join(random.sample(string.ascii_letters, 4)) + 
                         ''.join(random.sample(string.digits, 4))

        return ''.join(random.sample(password_chars, len(password_chars)))

    def _generate_zip(self, zip_name, password):
        """
        パスワード付きZipファイルのデータを生成
        """
        tmp_dir  = "/tmp/" + zip_name
        os.mkdir(tmp_dir)

        # 一旦ローカルにファイルを保存
        for attach_file in self.email_attr['attach_files']:
            f = open(tmp_dir + "/" + attach_file['name'], 'wb')
            f.write(attach_file['data'])
            f.flush()
            f.close()

        # パスワード付きzipに
        dst_file_path = "/tmp/%s.zip" % zip_name
        src_file_names = ["%s/%s" % (tmp_dir, name) for name in os.listdir(tmp_dir)]

        pyminizip.compress_multiple(src_file_names, dst_file_path, password, 4)

        # # 生成したzipファイルを読み込み
        r = open(dst_file_path, 'rb')
        zip_data = r.read()
        r.close()

        return zip_data

    def _forward_with_zip(self, zip_name, zip_data):
        """
        パスワード付きZipファイルのデータを生成
        """
        self._send_message(
                self.email_attr['subject'],
                self.email_attr["body"].encode(self.encode),
                zip_name,
                zip_data
                )
        return

    def _send_password(self, zip_name, password):
        """
        zipファイルのパスワードを送信
        """

        subject = self.email_attr['subject']

        message = """
先ほどお送りしたファイルのパスワードのお知らせです。

[件名] {}
[ファイル名] {}.zip
[パスワード] {}
        """.format(subject, zip_name, password)

        self._send_message(
                '[password]%s' % subject,
                message,
                None,
                None
                )
        return

    def _send_message(self, subject, message, attach_name, attach_data):
        """
        メール送信
        """

        msg = MIMEMultipart()

        # ヘッダ
        msg['Subject'] = subject
        msg['From']    = self.email_attr['from']
        msg['To']      = self.email_attr['reply_to']
        msg['Bcc']     = self.email_attr['from']

        # 本文
        body = MIMEText(message, 'plain', self.encode)
        msg.attach(body)

        # 添付ファイル
        if attach_data:
            file_name = "%s.zip" % attach_name
            attachment = MIMEBase('application', 'zip')
            attachment.set_param('name', file_name)
            attachment.set_payload(attach_data)
            encoders.encode_base64(attachment)
            attachment.add_header("Content-Dispositon", "attachment", filename=file_name)
            msg.attach(attachment)

        # 送信
        smtp_server   = self._get_decrypted_environ("SMTP_SERVER")
        smtp_port     = self._get_decrypted_environ("SMTP_PORT")
        smtp_user     = self._get_decrypted_environ("SMTP_USER")
        smtp_password = self._get_decrypted_environ("SMTP_PASSWORD")
        smtp = smtplib.SMTP(smtp_server, smtp_port)
        smtp.ehlo()
        smtp.starttls()
        smtp.ehlo()
        smtp.login(smtp_user, smtp_password)
        smtp.send_message(msg)
        smtp.quit()
        print("Successfully sent email")

        return

    def _get_decrypted_environ(self, key):
        """
        暗号化された環境変数を復号化
        """

        client = boto3.client('kms')
        encrypted_data = os.environ[key]
        return client.decrypt(CiphertextBlob=base64.b64decode(encrypted_data))['Plaintext'].decode('utf-8')

def lambda_handler(event, context):

    # イベントからバケット名、キー名を取得
    bucket = event['Records'][0]['s3']['bucket']['name']
    key = urllib.parse.unquote_plus(event['Records'][0]['s3']['object']['key'])

    try:
        # S3からファイルの中身を読み込む
        s3_object = s3.get_object(Bucket=bucket, Key=key)
        email_string = s3_object['Body'].read().decode('utf-8')

        # メールを解析
        parser = MailParser(email_string)

        # メール転送
        forwarder = MailForwarder(parser.get_attr_data())
        forwarder.send()
        return

    except Exception as e:
        print(e)
        raise e

pyminizip

パスワード付きzipは標準のライブラリじゃできないっぽい。
ということで、ここだけpyminizipという外部ライブラリに頼りました。
ただこれ、インストール時にビルドしてバイナリ作る系のライブラリだったので、Lambdaで動かすためにローカルでAmazonLinuxのDockerコンテナ立ててバイナリを作りました。何かほかにいい方法あるのかな。。

AWS SAM

ちなみに、これはAWS SAMを使ってローカルテストしてみました。
SMTPサーバーの情報を直書きして試してたところまでは良かったけど、それを環境変数に移すとうまく動かなくて挫折しました。修正はされてるけどリリースされてないっぽい。

導入方法

せっかくなので公開してみます。コードネームzaru
かなり設定方法が泥臭いままですがご容赦ください。。
https://github.com/Kta-M/zaru

自分の環境(Mac, Thunderbird)でしか試してないので、メーラーやその他環境によってはうまくいかないかも?自己責任でお願いします。

SES

SESはまだ東京リージョンで使えないので、オレゴンリージョン(us-west-2)で構築します。

ドメイン検証

まずはSESに向けてメールが送れるように、ドメインの検証を行います。
やり方はいろいろなので、このあたりは割愛。
たとえばこのあたりとか参考になるかも -> RailsでAmazon SES・Route53を用いてドメインメールを送信する

Rule作成

ドメインの検証ができたら、Ruleを作成します。

メニュー右側のRule Setsから、View Active Rule Setをクリック。
ses_rule_01.png

Create Ruleをクリック。
ses_rule_02.png

受信するメールアドレスを登録。検証を行なったドメインのメールアドレスを入力して、Add Recipientをクリック。
ses_rule_03.png

メール受信時のアクションを登録。
アクションタイプとしてS3を選択し、受信したメールデータを保存するバケットを指定します。このとき、Create S3 bucketでバケットを作成してあげると、必要なバケットポリシーが自動で登録されて便利。
SESからバケットへのファイルアップロードを許可するポリシーが設定されます。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowSESPuts-XXXXXXXXXXXX",
            "Effect": "Allow",
            "Principal": {
                "Service": "ses.amazonaws.com"
            },
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::<ses-bucket-name>/*",
            "Condition": {
                "StringEquals": {
                    "aws:Referer": "XXXXXXXXXXXX"
                }
            }
        }
    ]
}

また、バケットに保存されたメールデータは、貯めておいても仕方ないので、ライフサイクルを設定して一定期間経過後削除されるようにしておくといいかも。
ses_rule_04.png

ルールに名前を付けます。あとはデフォルトで。
ses_rule_05.png

登録内容を確認して、登録!
ses_rule_06.png

Lambda

デプロイ

SESと同じくオレゴンリージョンにデプロイします。
CloudFormationを利用するので、データをアップロードするS3バケットを作っておいてください。

# git clone git@github.com:Kta-M/zaru.git
# cd zaru
# aws cloudformation package --template-file template.yaml --s3-bucket <cfn-bucket-name> --output-template-file packaged.yaml
# aws cloudformation deploy --template-file packaged.yaml --stack-name zaru-stack --capabilities CAPABILITY_IAM --region us-west-2

Lambdaのコンソールに行くと、関数が作成されています。
また、この関数の実行に必要なIAMロールも作成されています。
lambda_01.png

トリガー設定

バケットにメールデータが入るのをトリガーにして、Lambdaが動くように設定します。

関数の詳細画面のトリガータブに移動します。
lambda_02.png

トリガーを追加をクリックし、S3のイベントを作成します。
SESからデータが来るバケット、イベントタイプはPutです。それ以外はデフォルト。
バケットはlambda_03.png

暗号化キーを作成

このLambda関数内では、暗号化された環境変数からSMTP関連の情報を取得しています。
その暗号化に使用するキーを作成します。

IAMコンソールから、左下にある暗号化キーをクリックします。
リージョンをオレゴンに変更し、キーを作成してください。
lambda_04.png

設定内容は、任意のエイリアスを設定するだけで、残りはデフォルトでOKです。
lambda_05.png

環境変数数設定

Lambdaに戻って、関数内で使用する環境変数を設定します。
コードタブの下のほうに、環境変数を設定するフォームがあります。
暗号化ヘルパーを有効にするにチェックを入れ、先ほど作成した暗号化キーを指定します。
環境変数は、変数名と値(平文)を入力し、暗号化ボタンを押します。すると、指定した暗号化キーで暗号化してくれます。
設定する環境変数は以下の4つです。

変数名 説明
SMTP_SERVER smtpサーバー smtp.example.com
SMTP_PORT smtpポート 587
SMTP_USER smtpサーバーにログインするユーザー名 test@example.com
SMTP_PASSWORD SMTP_USERのパスワード

lambda_06.png

ロール設定

最後に、このLambda関数を実行するロールに必要な権限を付けます。
– メールデータを保存するS3バケットからデータを取得する権限
– 暗号化キーを使って環境変数を復号する権限

まず、IAMコンソールのポリシーに行き、ポリシーの作成->独自のポリシーを作成で以下の2つのポリシーを作成します。
lambda_07.png

ポリシー:s3-get-object-zaru
<ses-bucket-name>には、SESからメールデータを受け取るバケット名を指定してください。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1505586008000",
            "Effect": "Allow",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::<ses-bucket-name>/*"
            ]
        }
    ]
}

ポリシー;kms-decrypt-zaru
<kms-arn>には、暗号化キーのARNを指定してください。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "Stmt1448696327000",
            "Effect": "Allow",
            "Action": [
                "kms:Decrypt"
            ],
            "Resource": [
                "<kms-arn>"
            ]
        }
    ]
}

最後に、この2つのポリシーを、Lambda関数実行ロールにアタッチします。
まず、IAMコンソールのロールに行き、ロールを選択し、ポリシーのアタッチからアタッチします。
lambda_08.png

動作確認

これで動くようになったはずです。
ToにSES向けに設定したメールアドレス、Reply-Toに相手のメールアドレスを設定し、適当なファイルを添付して送ってみてください。どうでしょう?

まとめ

どんとこいzip添付!

続きを読む

OpsWorksでCloudWatch Logs Agentのインストールにコケる

結論

python-dev のパッケージを自分で指定して入れないと動かないとのこと。

現象

今年くらいにOpsWorksの画面から、自前でエージェントをインストールしなくても直接CloudWatch Logsを利用出来るようになった。だが、いざやってみるとSetupがFailureになる。

image.png

root@app1:/var/log# /opt/aws/cloudwatch/awslogs-agent-setup.py -n -r 'ap-northeast-1' -c '/opt/aws/cloudwatch/cwlogs.cfg'

Step 1 of 5: Installing pip ...DONE

Step 2 of 5: Downloading the latest CloudWatch Logs agent bits ... Traceback (most recent call last):
  File "/opt/aws/cloudwatch/awslogs-agent-setup.py", line 1272, in <module>
    main()
  File "/opt/aws/cloudwatch/awslogs-agent-setup.py", line 1268, in main
After this operation, 35.1 MB of additional disk space will be used.
Do you want to continue? [Y/n] Y
Get:1 http://ap-northeast-1.ec2.archive.ubuntu.com/ubuntu/ trusty-updates/main libexpat1-dev amd64 2.1.0-4ubuntu1.4 [115 kB]
Get:2 http://ap-northeast-1.ec2.archive.ubuntu.com/ubuntu/ trusty-updates/main libpython2.7-dev amd64 2.7.6-8ubuntu0.3 [22.0 MB]
Get:3 http://ap-northeast-1.ec2.archive.ubuntu.com/ubuntu/ trusty/main libpython-dev amd64 2.7.5-5ubuntu3 [7,078 B]
Get:4 http://ap-northeast-1.ec2.archive.ubuntu.com/ubuntu/ trusty-updates/main python2.7-dev amd64 2.7.6-8ubuntu0.3 [269 kB]
Get:5 http://ap-northeast-1.ec2.archive.ubuntu.com/ubuntu/ trusty/main python-dev amd64 2.7.5-5ubuntu3 [1,166 B]
Fetched 22.4 MB in 0s (40.3 MB/s)
Selecting previously unselected package libexpat1-dev:amd64.
(Reading database ... 97703 files and directories currently installed.)
Preparing to unpack .../libexpat1-dev_2.1.0-4ubuntu1.4_amd64.deb ...
Unpacking libexpat1-dev:amd64 (2.1.0-4ubuntu1.4) ...
Selecting previously unselected package libpython2.7-dev:amd64.
Preparing to unpack .../libpython2.7-dev_2.7.6-8ubuntu0.3_amd64.deb ...
Unpacking libpython2.7-dev:amd64 (2.7.6-8ubuntu0.3) ...
Selecting previously unselected package libpython-dev:amd64.
Preparing to unpack .../libpython-dev_2.7.5-5ubuntu3_amd64.deb ...
Unpacking libpython-dev:amd64 (2.7.5-5ubuntu3) ...
Selecting previously unselected package python2.7-dev.
Preparing to unpack .../python2.7-dev_2.7.6-8ubuntu0.3_amd64.deb ...
Unpacking python2.7-dev (2.7.6-8ubuntu0.3) ...

ドキュメント

よくドキュメントを読んでみると、 python-dev が必須とのこと。

If the installation of the agent fails, check to make sure that the python-dev package is installed. If it isn’t, use the following command, and then retry the agent installation:
https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/QuickStartChef.html

pythonのプロジェクトではない人には、デフォルトでfailさせるのはどうなんですかね。文句いうならpythonを使えという感じですかね 😨

image.png

LayerのOS Packagesに追加して、無事に online になりました。完。

続きを読む

boto3(AWS SDK for python)を使って、ローカルファイルをS3にアップロードする方法

はじめに

boto2を使って、ローカルストレージにあるファイルをアップロードするQiita記事はよく見かけるが、
boto3を使ったものがなかなか見つからなかったので、公式サイトを参考に実装したものを貼っておく。

開発環境

Python 3.6.1
pip 9.0.1

手順

  • パッケージインストール
  • Configファイル設定
  • 実装 (pythonソースコード)

パッケージインストール

boto3(AWS SDK for python)

$ pip install boto3

コマンドラインよりAWSのサービスを操作するためのパッケージも併せてインストールしておく。

$ pip install awscli

Configファイル生成

アクセスキー、シークレットキー、リージョン等をConfigファイルに書き込む。
下記コマンドを実行し、後は対話型にデータを入力していけばホームディレクトリ配下にファイルが生成される。

※ boto2では、ソース上よりAWSアクセスキー、シークレットキーを読み込んでいたが、
boto3では、Configファイルから上記2つキーを取得する。

$ aws configure
AWS Access Key ID [None]: xxxxxxxxxxxxxxxxxxxxxxx
AWS Secret Access Key [None]: xxxxxxxxxxxxxxxxxxx
Default region name [None]: xxxxxxxxxxxxxxxxxxxxx
Default output format [None]: xxxxxxxxxxxxxxxxxxx

生成されるファイル(2つ)

~/.aws/credentials
----------------------------------------------
[default]
aws_access_key_id = ACCESS_KEY_ID
aws_secret_access_key = SECRET_ACCESS_KEY
----------------------------------------------
~/.aws/config
----------------------------------------------
[default]
region = [xxxxxxxxxxxxxxxxx]
output = [xxxxxxxxxxxxxxxxx]
----------------------------------------------

ホームディレクトリに生成され、boto3実行時もホームディレクトリの上記ファイルを読みに行っているので、
.awsのディレクトリを移動させるとエラーが発生するので注意。

botocore.exceptions.NoCredentialsError: Unable to locate credentials

もし移動させると、上記のようなエラーが発生する。
まぁ、普通の人は移動させないんだろうけど。。。

実装 (Pythonソースコード)


# -*- coding: utf-8 -*-

import sys
import threading

import boto3

# boto3 (.aws/config)
BUCKET_NAME = 'YOUR_BUCKET_NAME'

class ProgressCheck(object):
    def __init__(self, filename):
        self._filename = filename
        self._size = int(os.path.getsize(filename))
        self._seen_so_far = 0
        self._lock = threading.Lock()
    def __call__(self, bytes_amount):
        with self._lock:
            self._seen_so_far += bytes_amount
            percentage = (self._seen_so_far / self._size) * 100
            sys.stdout.write(
                    "r%s / %s (%.2f%%)" % (
                        self._seen_so_far, self._size,
                        percentage))
            sys.stdout.flush()


def UploadToS3():
    # S3Connection
    s3 = boto3.resource('s3')
    s3.Object(BUCKET_NAME, 'OBJECT_KEY (S3)').upload_file('UPLOAD_FILE_PATH (lOCAL)')

UploadToS3()  

OBJECT_KEY (S3) : S3でのオブジェクトキーを設定する。
UPLOAD_FILE_PATH (lOCAL) : アップロードするローカルファイルのパスを設定する。

終わりに

気が向いたら、boto2でのアップロード方法との比較も書きたいと思います。
最後までお読みいただき、ありがとうございました。

何か誤り、アップデート事項ございましたらご指摘お願いいたします。

続きを読む

【AWS Lambda】python で VPC mysql に接続する【mac】

macOS の環境

> sw_vers
ProductName:    Mac OS X
ProductVersion: 10.12.6
BuildVersion:   16G29

macOS 環境下のpythonのバージョン

> python -V
Python 2.7.10

作成開始

  • lambda をコンパイするためのディレクトリの作成
> mkdir lambda_app
  • mysql を接続するための情報を記述するファイルの作成
> vi rds_config.py
#config file credentials for rds mysql instance
db_username = "接続したいDBユーザ名"
db_password = "接続したいDBパスワード"
db_name = "接続したいDB名"
  • mysql を接続するアプリケーションの作成
> vi app.py
import sys
import logging
import rds_config
import pymysql
#rds settings
rds_host  = "tas-test.cvsmhdh6y1wz.us-east-1.rds.amazonaws.com"
name = rds_config.db_username
password = rds_config.db_password
db_name = rds_config.db_name

logger = logging.getLogger()
logger.setLevel(logging.INFO)

try:
    conn = pymysql.connect(rds_host, user=name, passwd=password, db=db_name, connect_timeout=5)
except:
    logger.error("ERROR: Unexpected error: Could not connect to MySql instance.")
    sys.exit()

logger.info("SUCCESS: Connection to RDS mysql instance succeeded")
def handler(event, context):
    """
    This function fetches content from mysql RDS instance
    """

    item_count = 0

    with conn.cursor() as cur:
        cur.execute("select * from 接続したいDB名")
        for row in cur:
            item_count += 1
            logger.info(row)


    return "Added %d items from RDS MySQL table" %(item_count)
  • lambda で接続するために pymysql をインストールする
pip install PyMySQL -t /任意のフォルダ/lambda_app
>ls
- pymysql
- PyMySQL-0.7.11.dist-info

この時、 app.py と同じ階層にファイルが無いとだめ

  • lambda 用に zip で固める
>zip -r ../app.zip *

これで完了。

設定など

作成した zip ファイルを AWS Lambda にアップロードをする.
python2.7 を選択する必要がある。
ハンドラは、上記の例だと app.handler となる。
正しくは、実行ファイル名+’.’+実行関数名である。
この時、 ロールには Basic with VPC を設定する必要がある。
詳細設定で、VPC の設定をする。
接続する RDB 側と Lambda のセキュリティグループに from-vpc を設定する。←結構ここで詰んだ

続きを読む

ど素人がつまづいた、シェルスクリプトの自動起動設定

ことの発端

上司「ぼく君さぁ・・・EC2のインスタンスを平日の営業時間だけ立ち上がってる状態にしてくれない?」

ぼく「!w」

上司「8:30に自動的に立ち上がって、18:30に自動停止させたらいいから。簡単でしょ?」

ぼく「?www!?w!?!wwwww」

上司「じゃ、まかせたね~」

ぼく「・・・w」

概要

・EC2インスタンスを8:30に立ち上げ、18:30に停止させる
・インスタンス立ち上げた際にwebアプリケーション立ち上げのシェルスクリプトを実行する

実装に必要なこと

ど素人がハマったこと

まず、やることはすごく単調で調べればそれらしい内容はいくらでも出てきたので、AWS上ではそこまで問題らしい問題は起こりませんでした。
(CloudWatchの時間設定方法がUTCなため、平日起動時間をミスって設定してしまい、インスタンスを土曜も立ち上がらせたくらい。)

ではどこでハマったかというと、

インスタンス立ち上げた際にwebアプリケーション立ち上げのシェルスクリプトを実行する

こいつ。

失敗1

[ /etc/rc.localに起動させたいシェルを書き込む ]

「自動起動 シェルスクリプト」
これで検索した時に一番初めにでてきたものがrc.localへの記述だったため、すぐ採用。

・結果

おっしゃ動いたやんけ!!!はい余裕~

ぼく「上司さん!できましたわ!」
上司「お、いいね!早いしちゃんと起動して・・・ん?シェル実行された時に生成されるディレクトリ諸々の権限違うくない?」
ぼく「えっ、」
⇒やりなおし

・原因

⇒/etc/rc.localはrootで実行される


失敗2

[ root権限で実行されるならスクリプト内にsuコマンド付け足してec2-userに変えたろ!! ]

sudo -u ec2-user bash << EOF
~~~~~~
EOF

・結果

⇒そもそも/etc/rc.localの記述スクリプトを読んでいない。

・原因

⇒不明。


失敗3

[ ユーザーを変更し、起動したいシェルを呼び出すためのスクリプトを作成、/etc/rc.localに記述 ]

・結果

⇒用意したシェルも/etc/rc.localからは呼び出されず。

・原因

⇒不明。このあたりから、suコマンドを用いたシェルは/etc/rc.localで使用できないのではないかと思い始める。泣きそう。


解決

上記の失敗1~3までの検証を行っている間に気がつけばAWS上の実装とは別に1人日消費しておりました。
この辺で思うように実装できないことよりも、意味の分からないタスクを寄こした上司を恨むように。

再度「自動起動 シェルスクリプト」で検索。

方法

[ crontab内にOSの起動時、自動起動させるためのコマンド@rebootを記述 ]

@reboot /bin/sh /home/ec2-user/bin/shell.sh
この一行を追加するだけ!!!だけ!!!!返してぼくの1人日!!!!!!

参考⇒ http://nort-wmli.blogspot.jp/2016/02/cron-osreboot-shellsh.html

まとめ

インスタンスの起動時、自動起動させたいシェルスクリプトの実行権限はrootで良い?
YES ⇒ /etc/rc.localに実行シェルを
NO ⇒ crontabの@rebootを使用する。


実装する時は策を2つ以上用意しとかないとこういうことになるんですね。
どっと疲れた。。。

ちなみに/etc/rc.localにsuを含んだシェルスクリプトを記述して実行されない理由は分かりませんでした。もしかしたら実行権限がないとか、そういった部分なのかも。

続きを読む

RaspberryPiに接続したセンサの情報をCloud Watchでモニタリングする

構想

RaspberryPiから送信したセンサの情報をAWSIoTを使って、DynamoDBに格納する。
格納した情報をLambdaを使ってCloudWatchに流し、モニタリングする。

AWS IoTにモノを登録する

まずはAWSIoTに接続するモノを登録する。

接続をクリック

1.png

デバイスの設定の今すぐ始めるをクリック

2.png

手順を確認して今すぐ始めるをクリック

3.png

接続するモノの環境を選択

今回はRaspberryPiを使用するのでOSはLinux、言語はPythonを選択する。

4.png

モノの名前を設定する

自分の好きな名前で良い。

5.png

接続キットをダウンロードする

今回登録したモノ専用の証明書などが同梱されているので、取扱に気をつける。

6.png

画面のコマンドを実行する

以前の記事を参考にRaspberryPiのPython環境を整えてから、画面に表示されたコマンドを実行してみる。

7.png

$ ./start.sh

2017-09-10 14:30:53,709 - AWSIoTPythonSDK.core.protocol.mqttCore - DEBUG - Try to put a publish request 2 in the TCP stack.
2017-09-10 14:30:53,710 - AWSIoTPythonSDK.core.protocol.mqttCore - DEBUG - Publish request 2 succeeded.
Received a new message: 
b'New Message 0'
from topic: 
sdk/test/Python
--------------


2017-09-10 14:30:54,713 - AWSIoTPythonSDK.core.protocol.mqttCore - DEBUG - Try to put a publish request 3 in the TCP stack.
2017-09-10 14:30:54,714 - AWSIoTPythonSDK.core.protocol.mqttCore - DEBUG - Publish request 3 succeeded.
Received a new message: 
b'New Message 1'
from topic: 
sdk/test/Python
--------------


2017-09-10 14:30:55,717 - AWSIoTPythonSDK.core.protocol.mqttCore - DEBUG - Try to put a publish request 4 in the TCP stack.
2017-09-10 14:30:55,718 - AWSIoTPythonSDK.core.protocol.mqttCore - DEBUG - Publish request 4 succeeded.
Received a new message: 
b'New Message 2'
from topic: 
sdk/test/Python
--------------

上記のようなメッセージが流れ出したら成功。

送信内容を変更する

上記で実行したサンプルプログラムでは、永遠とNew Message Xというメッセージを送り続けているだけなので、これを温度/湿度センサ(DHT11)の出力に変える。

まずは先程ダウンロードしたconnect_device_packageの直下にhttps://github.com/szazo/DHT11_Python.gitをクローンする。

そして、./start.shで呼び出されているbasicPubSub.pyの中身を改変する。

connect_device_package/aws-iot-device-sdk-python/samples/basicPubSub/basicPubSub.py

'''
/*
 * Copyright 2010-2016 Amazon.com, Inc. or its affiliates. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License").
 * You may not use this file except in compliance with the License.
 * A copy of the License is located at
 *
 *  http://aws.amazon.com/apache2.0
 *
 * or in the "license" file accompanying this file. This file is distributed
 * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
 * express or implied. See the License for the specific language governing
 * permissions and limitations under the License.
 */
 '''

from AWSIoTPythonSDK.MQTTLib import AWSIoTMQTTClient
from DHT11_Python import dht11
import RPi.GPIO as GPIO
import sys
import logging
import time
import argparse
import datetime
import json


# Custom MQTT message callback
def customCallback(client, userdata, message):
    print("Received a new message: ")
    print(message.payload)
    print("from topic: ")
    print(message.topic)
    print("--------------\n\n")

# Read in command-line parameters
parser = argparse.ArgumentParser()
parser.add_argument("-e", "--endpoint", action="store", required=True, dest="host", help="Your AWS IoT custom endpoint")
parser.add_argument("-r", "--rootCA", action="store", required=True, dest="rootCAPath", help="Root CA file path")
parser.add_argument("-c", "--cert", action="store", dest="certificatePath", help="Certificate file path")
parser.add_argument("-k", "--key", action="store", dest="privateKeyPath", help="Private key file path")
parser.add_argument("-w", "--websocket", action="store_true", dest="useWebsocket", default=False,
                    help="Use MQTT over WebSocket")
parser.add_argument("-id", "--clientId", action="store", dest="clientId", default="basicPubSub", help="Targeted client id")
parser.add_argument("-t", "--topic", action="store", dest="topic", default="sdk/test/Python", help="Targeted topic")

args = parser.parse_args()
host = args.host
rootCAPath = args.rootCAPath
certificatePath = args.certificatePath
privateKeyPath = args.privateKeyPath
useWebsocket = args.useWebsocket
clientId = args.clientId
topic = args.topic

if args.useWebsocket and args.certificatePath and args.privateKeyPath:
    parser.error("X.509 cert authentication and WebSocket are mutual exclusive. Please pick one.")
    exit(2)

if not args.useWebsocket and (not args.certificatePath or not args.privateKeyPath):
    parser.error("Missing credentials for authentication.")
    exit(2)

# Configure logging
logger = logging.getLogger("AWSIoTPythonSDK.core")
logger.setLevel(logging.DEBUG)
streamHandler = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
streamHandler.setFormatter(formatter)
logger.addHandler(streamHandler)

# Init AWSIoTMQTTClient
myAWSIoTMQTTClient = None
if useWebsocket:
    myAWSIoTMQTTClient = AWSIoTMQTTClient(clientId, useWebsocket=True)
    myAWSIoTMQTTClient.configureEndpoint(host, 443)
    myAWSIoTMQTTClient.configureCredentials(rootCAPath)
else:
    myAWSIoTMQTTClient = AWSIoTMQTTClient(clientId)
    myAWSIoTMQTTClient.configureEndpoint(host, 8883)
    myAWSIoTMQTTClient.configureCredentials(rootCAPath, privateKeyPath, certificatePath)

# AWSIoTMQTTClient connection configuration
myAWSIoTMQTTClient.configureAutoReconnectBackoffTime(1, 32, 20)
myAWSIoTMQTTClient.configureOfflinePublishQueueing(-1)  # Infinite offline Publish queueing
myAWSIoTMQTTClient.configureDrainingFrequency(2)  # Draining: 2 Hz
myAWSIoTMQTTClient.configureConnectDisconnectTimeout(10)  # 10 sec
myAWSIoTMQTTClient.configureMQTTOperationTimeout(5)  # 5 sec

# Connect and subscribe to AWS IoT
myAWSIoTMQTTClient.connect()
myAWSIoTMQTTClient.subscribe(topic, 1, customCallback)
time.sleep(2)

# initialize GPIO
GPIO.setwarnings(False)
GPIO.setmode(GPIO.BCM)
GPIO.cleanup()

# read data using pin 2
instance = dht11.DHT11(pin=2)

while True:
    result = instance.read()
    if result.is_valid():
        data = {'timestamp': str(datetime.datetime.now()),
                'clientId': clientId,
                'temperature': result.temperature,
                'humidity': result.humidity
                }
        myAWSIoTMQTTClient.publish(topic, json.dumps(data), 1)
        time.sleep(1)

DHT11の結果をディクショナリ形式で格納し、myAWSIoTMQTTClient.publish(topic, json.dumps(data), 1)json.dumpsしてパブリッシュする。

また、start.shも次の設定で扱いやすいように、
basicPubSub.pyの呼び出しオプションを追加する。

start.sh
# stop script on error
set -e

# Check to see if root CA file exists, download if not
if [ ! -f ./root-CA.crt ]; then
  printf "\nDownloading AWS IoT Root CA certificate from Symantec...\n"
  curl https://www.symantec.com/content/en/us/enterprise/verisign/roots/VeriSign-Class%203-Public-Primary-Certification-Authority-G5.pem > root-CA.crt
fi

# install AWS Device SDK for Python if not already installed
if [ ! -d ./aws-iot-device-sdk-python ]; then
  printf "\nInstalling AWS SDK...\n"
  git clone https://github.com/aws/aws-iot-device-sdk-python.git
  pushd aws-iot-device-sdk-python
  python setup.py install
  popd
fi

# run pub/sub sample app using certificates downloaded in package
printf "\nRunning pub/sub sample application...\n"
python aws-iot-device-sdk-python/samples/basicPubSub/basicPubSub.py -e a33hl2ob9z80a1.iot.us-west-2.amazonaws.com -r root-CA.crt -c RaspberryPi01.cert.pem -k RaspberryPi01.private.key -t dht11

追加したのは-t dht11という部分で、
これはトピックの名前を設定する部分である。

この名前を設定しておくことで、受信したメッセージがどのトピックのものか判別がつき、
AWSIoT側で受信後の処理を振り分けることができる。

AWS IAMで必要な権限を持ったロールを作成しておく

本来は機能毎にロールを分けて必要以上に権限を持たせないのが良いが、
面倒くさいので今回は一つのロールに全て持たせる。
(どの権限が必要になるかわからなかったので、多めにアタッチした。
あとで勉強しておく必要あり。)

  • CloudWatchFullAccess
  • AmazonDynamoDBFullAccess
  • AmazonDynamoDBFullAccesswithDataPipeline
  • AWSLambdaDynamoDBExecutionRole
  • AWSLambdaInvocation-DynamoDB

AWSIoTでルールを設定する

ルールをクリック

8.png

右上の作成ボタンをクリック

9.png

名前と説明を設定する

10.png

どのメッセージにルールを適用するかを設定する

属性は*、トピックフィルターは先程、basicPubSub.pyの呼び出しに追加した-tオプションの値を入力する。

こうすることで-tオプションで指定した名前と一致したもののみにこのルールを適用することができる。

11.png

メッセージに対してどのような処理を行うか決める。

今回はまず、AWS IoTで受信したメッセージをDynamoDBに送り込みたいので、そのように設定する。

12.png

13.png

新しいリソースを作成するをクリック。

14.png

DHT11のデータを格納するテーブルを作成する

15.png

16.png

こんな感じに設定して、テーブルを作成する。

テーブルを選択する

もとの画面を戻りテーブルを選択する。

17.png

ハッシュキーの値とレンジキーの値が空欄になっているので、
そこには上記のように${timestamp}などを指定する。

なお、メッセージとして送信されるjsonの各メンバーには${member}という形でアクセスできる。

ロールを指定する

18.png

ここには前もって用意しておいたロールを指定する。

19.png

これでアクションが追加できたので、ルールを作成するをクリックしてルール作成は完了。

確認

実行してみて、DynamoDBにデータが入るか確認する。

$ ./start.sh
 .
 .
 .

2017-09-09 23:32:23,411 - AWSIoTPythonSDK.core.protocol.mqttCore - DEBUG - Try to put a publish request 1394 in the TCP stack.
2017-09-09 23:32:23,412 - AWSIoTPythonSDK.core.protocol.mqttCore - DEBUG - Publish request 1394 succeeded.
Received a new message: 
b'{"timestamp": "2017-09-09 23:32:23.409885", "clientId": "basicPubSub", "temperature": 27, "humidity": 70}'
from topic: 
dht11
--------------


2017-09-09 23:32:25,715 - AWSIoTPythonSDK.core.protocol.mqttCore - DEBUG - Try to put a publish request 1395 in the TCP stack.
2017-09-09 23:32:25,716 - AWSIoTPythonSDK.core.protocol.mqttCore - DEBUG - Publish request 1395 succeeded.
Received a new message: 
b'{"timestamp": "2017-09-09 23:32:25.714083", "clientId": "basicPubSub", "temperature": 27, "humidity": 70}'
from topic: 
dht11
--------------

20.png

うまくいってるようだ。
NとかSとかは、NumberStringの略だそう。
最初NorthSouthかと思ってなんのこっちゃってなったけど。

DynamoDBからCloudWatchにデータを流す

トリガーの作成

長いけどあと一息。
DynamoDBのテーブルの画面から、トリガー => トリガーの作成 => 新規関数と選択。

21.png

22.png

トリガーの設定

ステップ1でdynamodb-process-streamが選択された状態で、ステップ2から始まるが、ステップ1に一旦戻る。

23.png

今回はdynamodb-process-streamのPython3版を選択した。

24.png

28.png

テーブルを指定し、開始位置は水平トリムにする。

25.png

トリガーの有効化は後でするので、チェックを入れずに次へ。

関数を定義する

26.png

適当に基本情報を埋める。

27.png

上図のように関数を定義できるので、以下のコードに変更する。

from __future__ import print_function

import json
import boto3
from decimal import Decimal

print('Loading function')


def lambda_handler(event, context):
    # print("Received event: " + json.dumps(event, indent=2))
    client = boto3.client('cloudwatch') 
    for record in event['Records']:
        # print(record['eventID'])
        # print(record['eventName'])
        # print("DynamoDB Record: " + json.dumps(record['dynamodb'], indent=2))
        print(record['dynamodb']['NewImage'])
        print(record['dynamodb']['NewImage']['timestamp']['S'])
        response = client.put_metric_data(
            Namespace='dht11',
            MetricData=[
                {
                    'MetricName': 'temperature',
                    'Dimensions': [
                        {
                            'Name': 'clientId',
                            'Value': record['dynamodb']['NewImage']['payload']['M']['clientId']['S'],
                        },
                    ],
                    'Value': Decimal(record['dynamodb']['NewImage']['payload']['M']['temperature']['N']),
                    'Unit': 'None'
                },
            ]
        ) 
        response = client.put_metric_data(
            Namespace='dht11',
            MetricData=[
                {
                    'MetricName': 'humidity',
                    'Dimensions': [
                        {
                            'Name': 'clientId',
                            'Value': record['dynamodb']['NewImage']['payload']['M']['clientId']['S'],
                        },
                    ],
                    'Value': Decimal(record['dynamodb']['NewImage']['payload']['M']['humidity']['N']),
                    'Unit': 'Percent'
                },
            ]
        )

編集したら、確認をして関数を保存する。

トリガーの有効化

DynamoDBのトリガーの画面に戻ると、作成した関数が出現している。
作成した関数を選択肢してトリガーの編集ボタンをクリック。

29.png

トリガーを選択してトリガーを有効化する。

30.png

CloudWatchでモニタリングする

あとはCloudWatch側で表示の設定をすれば、温度や湿度の推移が見えるようになる。
ダッシュボード => ダッシュボードの作成でダッシュボードを作成した後、
ウィジェットの追加からtemperaturehumidityを検索する。

Lambdaで作成されたメトリクスが出てくるので、それを選択すると・・・

31.png

無事、確認できる。

まとめ

  • AWSIoT => DynamoDB => Lambda => CloudWatchの連携で、DHT11センサの値をモニタリングすることができた。

続きを読む