AWS AthenaでBigデータ解析

WHY Athena

複数社のASPで吐き出されたフォーマットばらばらの膨大なcsvデータ達を結合しまくってデータ解析せなあかん時ってありますよね

  • いちいちDBサーバー作ってインポートしてらんない
  • いちいちフォーマット合わせてらんない
  • ExcelでVLOOKUPとか処理重すぎてクライアント動かない

そこでAthena

Analytics_AmazonAthena.png

WHAT Athena

  • サーバーレスで環境構築不要
  • S3にファイルを上げてAthenaのコンソール画面でSQL叩くだけで結果出力
  • Prestoベースなので重たいクエリの結果が早い
  • 出力されたデータはcsvでDL可能
  • S3の標準の金額とAthenaのクエリ従量なので安い(クエリのスキャン 1 TB あたり 5 USD)

いいことだらけ

Saving Point

リージョン

実行タイミングが毎日何百回とかでなければS3のリージョンは 米国東部 (バージニア北部) か 米国西部 (オレゴン) がオススメ

Athenaはどのリージョンでも金額は変わらない

スクリーンショット_2017-12-12_14_48_47.jpg

データ圧縮

本データがGZIP圧縮されてればその分スキャンデータが少なくなるので安くなる

列のチューニング

元データに不要な列はなるべく消すこと、消せば消した分だけスキャンデータが少なくなるので安くなる

列指向データ形式(Apache Parquet や Apache ORC)に形式を変換すれば不要な列を読まなくなるのでこれも安くなる

列の削除はExcelやエディタでの編集も良いが、AWS Glueを使うと良い

AWS Glue

Athenaの作業がもっと楽になるやつ
S3の本データをの変換が素早く出来る+Athena用のデータ形式に変換してくれる
他社都合で勝手にデータの列形式とか変わっちゃった時にお役立ち
東京リージョンデビューはもう少しかかる模様なので 米国東部 (バージニア北部) か 米国西部 (オレゴン) で

詳しい使い方はクラスメソッドさんのところ

パーティション

テーブル作成時にパーティション設定することで、クエリの条件に日付関連の方法を入れると、勝手に条件外のデータを読まなくなります

日毎とか週毎、月毎などに分類されるデータはパーティション設定するといいでしょう

パーティションにするにはS3に上げる時にディレクトリを dt=ほにゃらら にしてCREATE文に PARTITIONED BY って書けばいいです

こんな感じ

yakiniku.osushi.sql
CREATE EXTERNAL TABLE IF NOT EXISTS yakiniku.osushi (
  `name` string,
  `media_name` string,
  `media_type` string,
  `device` string,
  `carrier` string,
  `click_date` timestamp,
  `regist_date` timestamp,
  `user_id` string,
  `session_id` string 
) PARTITIONED BY (
  dt string 
)
ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.lazy.LazySimpleSerDe'
WITH SERDEPROPERTIES (
  'serialization.format' = ',',
  'field.delim' = ','
) LOCATION 's3://yakiniku-tabetai/osushi-tabetai/';

パーティション用のディレクトリを新たに作った場合は、テーブルに認識する必要があるので、Load partitionsしましょう

スクリーンショット_2017-12-12_14_28_15.jpg

Trouble Shooting

クエリは262144バイト以内

クエリが長すぎだとこんな風に怒られます、修正しましょう

Your query has the following error(s):

Your query has exceeded the maximum query length of 262144 bytes. Please reduce the length of your query and try again. If you continue to see this issue after reducing your query length, contact customer support for further assistance. (Service: AmazonAthena; Status Code: 400; Error Code: InvalidRequestException; Request ID: 1effab6b-df02-11e7-820b-c99df83a2b03)

稀にクエリが通らない

最近は頻度は減りましたが、並行で重たいクエリを投げてると稀にエラーになります

サーバレスアーキテクチャ上Lambdaみたいなもんだと思っているので、負荷によるエラーはないと思っているのですが、そういうときは時間を追いて再度実行するとクエリが通ります

S3 SELECTでいいんじゃね?

S3 SELECTはAWS re:Invent 2017で発表されたサービスで、S3にあるデータファイルを直接SELECT分で出力できるみたいです

今はプレビュー版ですが、AWSから許可してもらったので試した所、group by、order by 使えない等の制約があったため使っていません

あとCLI慣れてないと他にも躓くところがあるかもです

ただ、今後今回のようなケース(他社ASPからデータDLして分析)はS3 SELECTで十分になるかもしれません

あくまでAthenaはCLI苦手な人用となるのかも

おわり

続きを読む

「私、いくつに見える?」への返答機能をRoBoHoNに実装する(AWS Rekognition連携)

モバイル型ロボット電話 RoBoHoN に、AWS Rekognition APIを連携させ、ユーザーからの「私、いくつに見える?」という問いかけに返事をできるように実装しました。

キャプチャ.PNG

環境

Windows 7 SP1 64bit
Android Studio 2.3.1
RoBoHoN_SDK 1.2.0
RoBoHon端末ビルド番号 02.01.00
AWS SDK for Android 2.6.9

AWS Mobile SDK for Android 導入

  • 導入要件

 Android 2.3.3 (API Level 10) or higher

RoBoHoN は Android 5.0.2 (API Level 21 (Lollipop))

  • 参考

[AWS] Set Up the AWS Mobile SDK for Android
http://docs.aws.amazon.com/mobile/sdkforandroid/developerguide/setup.html

  1. Get the AWS Mobile SDK for Android.
  2. Set permissions in your AndroidManifest.xml file.
  3. Obtain AWS credentials using Amazon Cognito.

[qiita] Amazon Rekognitionで犬と唐揚げを見分けるアプリを作ってみた
https://qiita.com/unoemon/items/2bdf933127b6e225d036

  • 追加ライブラリ
compile 'com.amazonaws:aws-android-sdk-core:2.6.9'
compile 'com.amazonaws:aws-android-sdk-rekognition:2.6.9'
compile 'com.amazonaws:aws-android-sdk-cognito:2.6.9'

RoBoHoN実装

RoBoHoN独自の発語用 VoiceUI 、撮影用ライブラリ、そして Rekognition 通信を行き来をする実装を書いていきます。

  • Rekognition 通信部分参考

[AWS] Documentation » Amazon Rekognition » 開発者ガイド » Amazon Rekognition の開始方法 » ステップ 4: API の使用開始 » 演習 2: 顔の検出 (API)
http://docs.aws.amazon.com/ja_jp/rekognition/latest/dg/get-started-exercise-detect-faces.html

  • インターネット疎通とストレージパーミッション
AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
  • RoBoHonシナリオ関連定義類
ScenarioDefinitions.java
/**
*  Guess my age? シーン、accost、通知設定
*/
public static final String SCN_CALL = PACKAGE + ".guess.call";
public static final String SCN_RES = PACKAGE + ".guess.res";

public static final String ACC_CALL = ScenarioDefinitions.PACKAGE + ".guess.call";
public static final String ACC_RES = ScenarioDefinitions.PACKAGE + ".guess.res";


public static final String FUNC_CALL = "guess_call";
public static final String FUNC_RES = "guess_res";


/**
* memory_pを指定するタグ
*/
public static final String MEM_P_RES = ScenarioDefinitions.TAG_MEMORY_PERMANENT + ScenarioDefinitions.PACKAGE + ".res"; 
  • HVML(ユーザーからの呼びかけで起動、年齢判定のための写真撮影の声かけ、年齢判定を通知する)
home.hvml
<?xml version="1.0" ?>
<hvml version="2.0">
    <head>
        <producer>com.dev.zdev.rekog</producer>
        <description>いくつにみえる?のホーム起動シナリオ</description>
        <scene value="home" />
        <version value="1.0" />
        <situation priority="78" topic_id="start" trigger="user-word">${Local:WORD_APPLICATION} eq
            いくつにみえる
        </situation>
        <situation priority="78" topic_id="start" trigger="user-word">
            ${Local:WORD_APPLICATION_FREEWORD} eq いくつにみえる
        </situation>
    </head>
    <body>
        <topic id="start" listen="false">
            <action index="1">
                <speech>${resolver:speech_ok(${resolver:ok_id})}</speech>
                <behavior id="${resolver:motion_ok(${resolver:ok_id})}" type="normal" />
                <control function="start_activity" target="home">
                    <data key="package_name" value="com.dev.zdev.rekog" />
                    <data key="class_name" value="com.dev.zdev.rekog.MainActivity" />
                </control>
            </action>
        </topic>
    </body>
</hvml>
rekog_call.hvml
<?xml version="1.0" ?>
<hvml version="2.0">
    <head>
        <producer>com.dev.zdev.rekog</producer>
        <description>Guess my age? 撮影呼びかけ</description>
        <scene value="com.dev.zdev.rekog.guess.call" />
        <version value="1.0" />
        <accost priority="75" topic_id="call" word="com.dev.zdev.rekog.guess.call" />
    </head>
    <body>
        <topic id="call" listen="false">
            <action index="1">
                <speech>お顔をよーく見せて……。写真を撮るよ…。<wait ms="300"/></speech>
                <behavior id="assign" type="normal" />
            </action>
            <action index="2">
                <control function="guess_call" target="com.dev.zdev.rekog"/>
            </action>
        </topic>
    </body>
</hvml>
rekog_res.hvml
<?xml version="1.0" ?>
<hvml version="2.0">
    <head>
        <producer>com.dev.zdev.rekog</producer>
        <description>Guess my age? 結果通知</description>
        <scene value="com.dev.zdev.rekog.guess.res" />
        <version value="1.0" />
        <accost priority="75" topic_id="call" word="com.dev.zdev.rekog.guess.res" />
    </head>
    <body>
        <topic id="call" listen="false">
            <action index="1">
                <speech>${memory_p:com.dev.zdev.rekog.res}にみえるみたいだよ</speech>
                <behavior id="assign" type="normal" />
            </action>
            <action index="2">
                <control function="guess_res" target="com.dev.zdev.rekog"/>
            </action>
        </topic>
    </body>
</hvml>
  • MainActivity(各所抜粋)
MainActivity.java

private boolean hascall;

//onCreate ファンクション にカメラ連携起動結果取得用レシーバー登録
        mCameraResultReceiver = new CameraResultReceiver();
        IntentFilter filterCamera = new IntentFilter(ACTION_RESULT_TAKE_PICTURE);
        registerReceiver(mCameraResultReceiver, filterCamera);

//onResume ファンクション にScenn有効化追記
        VoiceUIManagerUtil.enableScene(mVoiceUIManager, ScenarioDefinitions.SCN_CALL);
        VoiceUIManagerUtil.enableScene(mVoiceUIManager, ScenarioDefinitions.SCN_RES);

//onResume ファンクション にScenn有効化後、即時発話(初回のみ)

        if (mVoiceUIManager != null && !hascall) {
            VoiceUIVariableListHelper helper = new VoiceUIVariableListHelper().addAccost(ScenarioDefinitions.ACC_CALL);
            VoiceUIManagerUtil.updateAppInfo(mVoiceUIManager, helper.getVariableList(), true);
        }

//onPause ファンクション にScene無効化
        VoiceUIManagerUtil.disableScene(mVoiceUIManager, ScenarioDefinitions.SCN_CALL);
        VoiceUIManagerUtil.disableScene(mVoiceUIManager, ScenarioDefinitions.SCN_RES);        

//onDestroy ファンクション にカメラ連携起動結果取得用レシーバー破棄
        this.unregisterReceiver(mCameraResultReceiver);

    /**
     * VoiceUIListenerクラスからのコールバックを実装する.
     */
    @Override
    public void onExecCommand(String command, List<VoiceUIVariable> variables) {
        Log.v(TAG, "onExecCommand() : " + command);
        switch (command) {
            case ScenarioDefinitions.FUNC_CALL:
                // 写真呼びかけ状況設定
                hascall =true;
                //写真を撮る
                sendBroadcast(getIntentForPhoto(false));
                break;
            case ScenarioDefinitions.FUNC_RES:
                finish();
                break;
            case ScenarioDefinitions.FUNC_END_APP:
                finish();
                break;
            default:
                break;
        }
    }


    /**
     * カメラ連携の結果を受け取るためのBroadcastレシーバー クラス
     * それぞれの結果毎に処理を行う.
     */
    private class CameraResultReceiver extends BroadcastReceiver {
        @Override
        public void onReceive(Context context, Intent intent) {
            String action = intent.getAction();
            Log.v(TAG, "CameraResultReceiver#onReceive() : " + action);
            switch (action) {
                case ACTION_RESULT_FACE_DETECTION:
                    int result = intent.getIntExtra(FaceDetectionUtil.EXTRA_RESULT_CODE, FaceDetectionUtil.RESULT_CANCELED);
                    break;
                case ACTION_RESULT_TAKE_PICTURE:
                    result = intent.getIntExtra(ShootMediaUtil.EXTRA_RESULT_CODE, ShootMediaUtil.RESULT_CANCELED);
                    if(result == ShootMediaUtil.RESULT_OK) {
                        // 1. 撮影画像ファイルパス取得 
                        final String path = intent.getStringExtra(ShootMediaUtil.EXTRA_PHOTO_TAKEN_PATH);
                        Log.v(TAG, "PICTURE_path : " + path);
                        Thread thread = new Thread(new Runnable() {public void run() {
                            try {
                                // 2. APIリクエスト、レスポンス取得
                                String res = (new GetAge()).inquireAge(path, getApplicationContext());
                                Log.v(TAG, "onExecCommand: RoBoHoN:" + res);
                                int ret = VoiceUIVariableUtil.setVariableData(mVoiceUIManager, ScenarioDefinitions.MEM_P_RES, res);
                                VoiceUIManagerUtil.stopSpeech();
                                // 3. RoBoHon 結果発話
                                if (mVoiceUIManager != null) {
                                    VoiceUIVariableListHelper helper = new VoiceUIVariableListHelper().addAccost(ScenarioDefinitions.ACC_RES);
                                    VoiceUIManagerUtil.updateAppInfo(mVoiceUIManager, helper.getVariableList(), true);
                                }
                            } catch (Exception e) {
                                Log.v(TAG, "onExecCommand: Exception" +  e.getMessage());
                            };
                        }});
                        thread.start();}
                    }
                    break;
                case ACTION_RESULT_REC_MOVIE:
                    result = intent.getIntExtra(ShootMediaUtil.EXTRA_RESULT_CODE, ShootMediaUtil.RESULT_CANCELED);
                    break;
                default:
                    break;
            }
        }
    }
  • AWS Rekognition通信
MainActivity.java
package com.dev.zdev.rekog;

/**
 * Created by zdev on 2017/12/10.
 */

import android.util.Log;
import android.content.Context;

import android.graphics.Bitmap;
import android.graphics.BitmapFactory;

import com.amazonaws.auth.CognitoCachingCredentialsProvider;
import com.amazonaws.regions.Regions;
import com.amazonaws.services.rekognition.AmazonRekognitionClient;

import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.util.List;

import com.amazonaws.services.rekognition.model.DetectFacesRequest;
import com.amazonaws.services.rekognition.model.DetectFacesResult;
import com.amazonaws.services.rekognition.model.FaceDetail;
import com.amazonaws.services.rekognition.model.AgeRange;
import com.amazonaws.services.rekognition.model.Image;

public class GetAge {
    private static final String TAG = GetAge.class.getSimpleName();
    private static int newWidth = 326;
    private static int newHeight = 244;

    private String resMsg = "ゼロさいからひゃくさいの間 ";
    AmazonRekognitionClient amazonRekognitionClient = null;

    private Bitmap resizeImag(String path){
        return Bitmap.createScaledBitmap(BitmapFactory.decodeFile(path), newWidth, newHeight, true);
    };

    private void setAmazonRekognitionClient(Context appcontext){
        // Amazon Cognito 認証情報プロバイダーを初期化します
        CognitoCachingCredentialsProvider credentialsProvider = new CognitoCachingCredentialsProvider(
            appcontext,
            "us-east-1:XXXXXXXXXXXX", // ID プールの ID
            Regions.US_EAST_1 // リージョン
        );
        this.amazonRekognitionClient =  new AmazonRekognitionClient(credentialsProvider);
    };

    public synchronized String inquireAge(String path, Context appcontext) throws Exception {

        try {
            Bitmap img = resizeImag(path);

            if (this.amazonRekognitionClient == null) {
                setAmazonRekognitionClient(appcontext);
            }

            ByteBuffer imageBytes = null;
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            img.compress(Bitmap.CompressFormat.JPEG, 100, baos);
            imageBytes = ByteBuffer.wrap(baos.toByteArray());

            DetectFacesRequest request = new DetectFacesRequest()
                    .withImage(new Image()
                            .withBytes(imageBytes))
                    .withAttributes("ALL");

            DetectFacesResult result = amazonRekognitionClient.detectFaces(request);
            List<FaceDetail> faceDetails = result.getFaceDetails();

            Log.v(TAG, "inquireAge: " + faceDetails.toString());

            for (FaceDetail face: faceDetails) {
                if (request.getAttributes().contains("ALL")) {
                    AgeRange ageRange = face.getAgeRange();
                    Log.v(TAG, "inquireAge: " + ageRange.getLow().toString() + " and " + ageRange.getHigh().toString() + " years old.");
                    this.resMsg = ageRange.getLow().toString() + " さいから " + ageRange.getHigh().toString() + " さいの間 ";
                } else { // non-default attributes have null values.
                    Log.v(TAG, "inquireAge: " + "Here's the default set of attributes:");
                }
            }

            return this.resMsg;

        } catch (Exception e) {
            Log.v(TAG, "inquireAge: exception: " + e.toString());
            return this.resMsg;
        }
    }
}

実行の様子と感想

年末も近いし、色々とひとが集まる機会に使えたらいいな、と、パーティアプリにしたく、実装しました。
(Microsoft「How-Old.net」が流行ったときのように、1~2回/人 試して場が沸いたら上出来!という)

家族で試したところ、下記のようになりました。

  • 30代後半男性: 45 ~ 63 才 (!)の間
  • 30代後半女性: 26 ~ 38 才の間
  • 9才 … 6 ~ 13 才の間
  • 4才 … 4 ~ 7 才の間、4 ~ 9 才の間

私(30代後半)は 「14 ~ 23 才の間」というスコアも叩き出せたので、家族が沸き「ロボホンがひいきしてる!(子供たちの声)」「なんか匙加減して実装してない?」など、”結果をロボホンが発声する”1 という文脈も楽しめました。



  1. ロボホンSDK内規定(0201_SR01MW_Personality_and_Speech_Regulations_V01_00_01)には “ロボホンはユーザに対して誠実です。ユーザを裏切ることはありません(中略) 不確実な情報を話すときは、それとわかる言い回しをする(例:「雨になるよ」→「予報によると、雨になるみたいだよ」” があります。そのため、今回も結果通知の語尾に「~才にみえるよ」でなく「~才にみえるみたいだよ」とつけています。(また、同規定書には “主観を持たない”というくだり(”ロボホンはロボットです 。ロボットは 、基本的に プログラムされたとおりに動くものです 。ロボホン自身の主観(好き嫌いや感想など ロボホン自身の主観(好き嫌いや感想など 、人によって感じ方が変わるもの 、人によって感じ方が変わるもの )は 持たないのが基本的考え方です 。”)ともあります) 

続きを読む

AWS SDK for JavaScript with Angular で Upload to S3.

欠員が出たということで、穴埋めさせていただきます。

概要

本記事は、AngularAWS SDK for JavaScriptを利用して、S3にファイルをアップロードするという内容です。
Angular メインですので、AWSサービスの使い方や設定については割愛いたします。ご了承ください。

環境

$ uname -a
Linux ip-10-4-0-188 4.9.62-21.56.amzn1.x86_64 #1 SMP Thu Nov 16 05:37:08 UTC 2017 x86_64 x86_64 x86_64 GNU/Linux
$ ng -v

    _                      _                 ____ _     ___
   /    _ __   __ _ _   _| | __ _ _ __     / ___| |   |_ _|
  / △  | '_  / _` | | | | |/ _` | '__|   | |   | |    | |
 / ___ | | | | (_| | |_| | | (_| | |      | |___| |___ | |
/_/   __| |_|__, |__,_|_|__,_|_|       ____|_____|___|
               |___/

Angular CLI: 1.5.5
Node: 8.9.1
OS: linux x64
Angular: 5.0.0
... animations, common, compiler, compiler-cli, core, forms
... http, language-service, platform-browser
... platform-browser-dynamic, router

@angular/cli: 1.5.5
@angular-devkit/build-optimizer: 0.0.35
@angular-devkit/core: 0.0.22
@angular-devkit/schematics: 0.0.41
@ngtools/json-schema: 1.1.0
@ngtools/webpack: 1.8.5
@schematics/angular: 0.1.10
@schematics/schematics: 0.0.10
typescript: 2.4.2
webpack: 3.8.1
package.json
{
  "name": "qiita",
  "version": "0.0.0",
  "license": "MIT",
  "scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",
    "test": "ng test",
    "lint": "ng lint",
    "e2e": "ng e2e"
  },
  "private": true,
  "dependencies": {
    "@angular/animations": "^5.0.0",
    "@angular/common": "^5.0.0",
    "@angular/compiler": "^5.0.0",
    "@angular/core": "^5.0.0",
    "@angular/forms": "^5.0.0",
    "@angular/http": "^5.0.0",
    "@angular/platform-browser": "^5.0.0",
    "@angular/platform-browser-dynamic": "^5.0.0",
    "@angular/router": "^5.0.0",
    "core-js": "^2.4.1",
    "rxjs": "^5.5.2",
    "zone.js": "^0.8.14"
  },
  "devDependencies": {
    "@angular/cli": "1.5.5",
    "@angular/compiler-cli": "^5.0.0",
    "@angular/language-service": "^5.0.0",
    "@types/jasmine": "~2.5.53",
    "@types/jasminewd2": "~2.0.2",
    "@types/node": "~6.0.60",
    "codelyzer": "^4.0.1",
    "jasmine-core": "~2.6.2",
    "jasmine-spec-reporter": "~4.1.0",
    "karma": "~1.7.0",
    "karma-chrome-launcher": "~2.1.1",
    "karma-cli": "~1.0.1",
    "karma-coverage-istanbul-reporter": "^1.2.1",
    "karma-jasmine": "~1.1.0",
    "karma-jasmine-html-reporter": "^0.2.2",
    "protractor": "~5.1.2",
    "ts-node": "~3.2.0",
    "tslint": "~5.7.0",
    "typescript": "~2.4.2"
  }
}

手順

1) AWS SDK for JavaScriptをインストール

$ npm i --save-prod aws-sdk

2) src/app/tsconfig.app.json を編集

tsconfig.app.json
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "outDir": "../out-tsc/app",
    "baseUrl": "./",
    "module": "es2015",
-    "types": []
+    "types": ["node"]
  },
  "exclude": [
    "test.ts",
    "**/*.spec.ts"
  ]
}

参照: Usage_with_TypeScript

3) S3アップロード用コンポネントを作成

$ ng g component s3-upload
  create src/app/s3-upload/s3-upload.component.css (0 bytes)
  create src/app/s3-upload/s3-upload.component.html (28 bytes)
  create src/app/s3-upload/s3-upload.component.spec.ts (643 bytes)
  create src/app/s3-upload/s3-upload.component.ts (280 bytes)
  update src/app/app.module.ts (408 bytes)

4) 各種ファイルを編集

src/app/app.component.html

+ <app-s3-upload></app-s3-upload>

src/app/app.module.ts

import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';


import { AppComponent } from './app.component';
import { S3UploadComponent } from './s3-upload/s3-upload.component';
import { S3Service } from './s3-upload/s3.service';

@NgModule({
  declarations: [
    AppComponent,
    S3UploadComponent,
  ],
  imports: [
    BrowserModule,
    HttpClientModule,
  ],
  providers: [
    S3Service,
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

src/app/s3-upload/s3-upload.html

<div class="form">
  <div class="form-group">
    <label class="col-xs-2">select file</label>
    <div class="col-xs-10">
      <input type="file" (change)="upload($event)">
    </div>
  </div>
</div>
<div class="debug_print">
  <p>httpUploadProgress {{ httpUploadProgress | json }}</p>
</div>

src/app/s3-upload/s3-upload.component.ts

import { Component, OnInit } from '@angular/core';
import { S3Service } from './s3.service';
import * as AWS from 'aws-sdk';

@Component({
  selector: 'app-s3-upload',
  templateUrl: './s3-upload.component.html',
  styleUrls: ['./s3-upload.component.css']
})
export class S3UploadComponent implements OnInit
{
  public httpUploadProgress: {[name: string]: any} = {
    ratio : 0,
    style : {
      width: '0',
    }
  };


  /**
   * @desc constructor
   */
  constructor(private s3Service: S3Service)
  {
    this.s3Service.initialize()
      .subscribe((res: boolean) => {
        if (! res) {
          console.log('S3 cognito init error');
        }
      })
  }


  /**
   * @desc Angular LifeCycle
   */
  ngOnInit()
  {
    this.s3Service.progress
      .subscribe((res: AWS.S3.ManagedUpload.Progress) => {
        this.httpUploadProgress.ratio = res.loaded * 100 / res.total;
        this.httpUploadProgress.style.width = this.httpUploadProgress.ratio + '%';
      });
  }


  /**
   * @desc file upload
   */
  public upload(event: Event): void
  {
    this.httpUploadProgress.ratio = 0;
    this.httpUploadProgress.style.width = '0';
    this.s3Service.onManagedUpload((<HTMLInputElement>event.target).files[0]);
  }
}

src/app/s3-upload/s3.service.ts

import { Injectable, EventEmitter } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/map';
import * as AWS from 'aws-sdk';
import { AWS_ENV } from '../../environments/environment';

@Injectable()
export class S3Service
{
  private s3: AWS.S3;
  public progress: EventEmitter<AWS.S3.ManagedUpload.Progress> = new EventEmitter<AWS.S3.ManagedUpload.Progress>();


  constructor(private http: HttpClient) { }


  /**
   * @desc set CognitoIdentityId
   * 
   * @return string IdentityId: ex) ap-northeast-1:01234567-9abc-df01-2345-6789abcd
   */
  public initialize(): Observable<boolean>
  {
    return this.http.get('/assets/cognito.php')
      .map((res: any) => {
        // resに対する例外処理を追加する
        // ...

        // Amazon Cognito 認証情報プロバイダーを初期化します
        AWS.config.region = AWS_ENV.region;
        AWS.config.credentials = new AWS.CognitoIdentityCredentials({
          IdentityId: res.results[0].IdentityId,
        });
        this.s3 = new AWS.S3({
          apiVersion: AWS_ENV.s3.apiVersion,
          params: {Bucket: AWS_ENV.s3.Bucket},
        });
        return true;
      })

      .catch((err: HttpErrorResponse) => {
        console.error(err);
        return Observable.of(false);
      });
  }

  /**
   * @desc AWS.S3
   */
  public onManagedUpload(file: File): Promise<AWS.S3.ManagedUpload.SendData>
  {
    let params: AWS.S3.Types.PutObjectRequest = {
      Bucket: AWS_ENV.s3.Bucket,
      Key: file.name,
      Body: file,
    };
    let options: AWS.S3.ManagedUpload.ManagedUploadOptions = {
      params: params,
      partSize: 64*1024*1024,
    };
    let handler: AWS.S3.ManagedUpload = new AWS.S3.ManagedUpload(options);
    handler.on('httpUploadProgress', this._httpUploadProgress.bind(this));
    handler.send(this._send.bind(this));
    return handler.promise();
  }

  /**
   * @desc progress
   */
  private _httpUploadProgress(progress: AWS.S3.ManagedUpload.Progress): void
  {
    this.progress.emit(progress);
  }

  /**
   * @desc send
   */
  private _send(err: AWS.AWSError, data: AWS.S3.ManagedUpload.SendData): void
  {
    console.log('send()', err, data);
  }
}

src/environments/environment.ts

// The file contents for the current environment will overwrite these during build.
// The build system defaults to the dev environment which uses `environment.ts`, but if you do
// `ng build --env=prod` then `environment.prod.ts` will be used instead.
// The list of which env maps to which file can be found in `.angular-cli.json`.

export const environment = {
  production: false
};

export const AWS_ENV = {
  region: 'YOUR_REGION',
  s3: {
    apiVersion: 'YOUR_VERSION',
    Bucket: 'YOUR_BACKET',
  },
};

5) ビルド&実行&確認

test.png

$ aws s3 ls s3://YOUR_BACKET/
2017-12-10 08:13:54   10485760 10MB.bin

解説

AWS SDKを使ってファイルをアップロードするには、PutObject()を使うのが手っ取り早いですが
JSからファイルをアップロードするときには、UI/UXの観点からプログレスを表示してあげるのがよいので
それに対応したメソッドである、ManagedUpload()を利用しました。

5.0.0 では、zoneを意識することなく、プログレスがきちんとレンダリングされましたので
プログレスバーの実装は容易に行なえます。

以上、穴埋め記事でした。

7日目は、@segawm さんです。

続きを読む

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の具体的な使い方を紹介する記事がもっと増えますように)

続きを読む

ラズパイでスマートスピーカーを自作(stretch)

myalexa.jpeg

ポストスマホの有力候補といえばスマートスピーカーということで、とりあえずデバイス側を作ってみました。
今回は米国で既に先行しているamazon Alexaとやり取りしたいと思います。
なんか色々アレクサと対話したいので英語、米語、ドイツ語、日本語と切り替えれるようにしました。

用意するもの

  • プロト基盤:Raspberry Pi3(2でも良いがwifiが付いているので3)
  • スピーカー:USB,3.5mmでもいいしHDMIでもいい
  • mircoSD:RSPIとの依存があるのでこの中から選ぶことを強くおすすめします。
  • USBキーボード:ラズパイ操作用
  • USBマウス:ラズパイ操作用
  • HDMIケーブル:モニターにつなぐため
  • HDMIモニタ:ラズパイ操作用
  • macbook:OSダウンロード用

大まかな流れ

ラズパイの設定
1. OSインストール
2. OSセットアップ
AVSの構築
1. SDKインストールと環境設定
2. SDKのビルド
3. AVSの認証
4. 実行

はい、では行ってみよう。

ラズパイの設定

1. OSインストール
まずはOS(raspbian)をインストールしましょう。
1. 1 OSのダウンロード
このサイトからダウンロード(https://www.raspberrypi.org/downloads/)

今回はraspbianというDebianベースのOSを使います。
ここにはNoobsという初心者用のインストーラーもあるけど、イケてる君は
Bootイメージを直接扱った方が時短になるので迷わずRasbian stretch2017-09-07を選ぼう。
※国内外の文献ではAVSのSDKがstretchに対応していないと書いているけど、そこはクリアしているので大丈夫。

1.2 SDカードのフォーマット

  • macbookにmicroSDカードを差す。
  • マウントが出来たらSDカードのフォーマットを行う。
  • SDカードの場所を確認
    $diskutil list
    これでSDカードのディレクトリが分かります。(ex:/dev/disk2)

  • SDカードのフォーマット(FATね)
    $sudo diskutil eraseDisk FAT32 RASBIAN MBRFormat /dev/disk2

    SDカードのサイズが30GB以下はFAT32とし、それ以上のサイズはexFATする。
    RASBIANは任意で好きな名前を付けてね

  • SDカードをアンマウントし、コピーができる状態が完了
    $sudo diskuitl unmount /dev/disk2
    disk2をアンマウントする

1.3 SDカードのコピー

  • ダウンロードしたOSを解凍
    $tar xzf 2017-09-07-raspbian-stretch.zip

  • 解凍したイメージをSDカードにコピー
    $sudo dd bs=1m if=2017-09-07-raspbian-stretch.img of=/deb/rdsik2

    なぜデバイス名rdiskになってるの?とお気づきですか。
    それはrdiskの方が書き込む速度が早いからです。
    disk2もrdisk2も同じ場所ですが、disk2とした場合、ランダムアクセスが可能となるデバイスとして転送データがUserSpaceという4KBのバッファを経由して処理されます。
    これはマルチタスクがゆえのことなのですが、これをrdiskで指定することにより、バッファを経由せずにダイレクトでシーケンシャルに処理が進むためにdiskよりも高速になります。
    興味と時間のある方はdisk指定で4GBのコピーの旅をお楽しみ下さい。

2 OSセットアップ
いよいよラズパイを動かしましょう。

2.1 OSの起動
RSPI3にモニタ、キーボード、マウス、SDカードを入れて電源ON
rasbian.jpeg

2.2 初期セットアップ

  • ターミナルを起動し次の設定を行う
    sudo raspi-configでもいいしGUIでもOK。とにかくこれを設定
    ・タイムゾーンの設定
    ・wifiの設定
    ・SSHの設定
    ・キーボード設定
    ※お好みに合わせてVNCも設定

AVSの構築

ここから本番。
1. SDKインストールと環境設定
1.1 まずは最低限必要なパッケージの更新とインストール

  • パッケージの更新
    sudo apt-get update

  • で、必要なパッケージをインストール

sudo apt-get -y install git gcc cmake build-essential libsqlite3-dev libcurl4-openssl-dev libfaad-dev libsoup2.4-dev libgcrypt20-dev libgstreamer-plugins-bad1.0-dev gstreamer1.0-plugins-good libasound2-dev doxygen

1.2 開発環境のディレクトリを作成

cd /home/pi/ && mkdir sdk-folder && cd sdk-folder && mkdir sdk-build sdk-source third-party application-necessities && cd application-necessities && mkdir sound-files

1.3 フリーの音声ライブラリ(portaudio)を拝借
ディレクトリ移動&ダウンロード&解凍&configure&makeを一気に行います

cd /home/pi/sdk-folder/third-party && wget -c http://www.portaudio.com/archives/pa_stable_v190600_20161030.tgz && tar zxf pa_stable_v190600_20161030.tgz && cd portaudio && ./configure --without-jack && make

1.4 commentjsonのインストール
AVS認証時に必要になるAlexaClientSDKConfig.jsonに書き込みを行うために必要
pip install commentjson

1.5 タイマーとアラーム音をアマゾンのサイトからダウンロード

cd /home/pi/sdk-folder/application-necessities/sound-files/ && wget -c https://images-na.ssl-images-amazon.com/images/G/01/mobile-apps/dex/alexa/alexa-voice-service/docs/audio/states/med_system_alerts_melodic_02._TTH_.mp3 && wget -c https://images-na.ssl-images-amazon.com/images/G/01/mobile-apps/dex/alexa/alexa-voice-service/docs/audio/states/med_system_alerts_melodic_02_short._TTH_.wav && wget -c https://images-na.ssl-images-amazon.com/images/G/01/mobile-apps/dex/alexa/alexa-voice-service/docs/audio/states/med_system_alerts_melodic_01._TTH_.mp3 && wget -c https://images-na.ssl-images-amazon.com/images/G/01/mobile-apps/dex/alexa/alexa-voice-service/docs/audio/states/med_system_alerts_melodic_01_short._TTH_.wav

2. SDKのビルド
2.1 AVSのSDKをクローンします

cd /home/pi/sdk-folder/sdk-source && git clone git://github.com/alexa/avs-device-sdk.git

2.2 ウェイクワードのエンジン(Sensory)もクローン
これをすると「アレクサ!」で反応してくれるようになります。

cd /home/pi/sdk-folder/third-party && git clone git://github.com/Sensory/alexa-rpi.git

で、それの利用規約に同意します。

cd /home/pi/sdk-folder/third-party/alexa-rpi/bin/ && ./license.sh 

2.3 日本語化対応
デフォルトは英語なので、これらのソースを変更し日本語に対応させます。

まずはエンドポイントを日本に変更

/sdk-folder/sdk-source/avs-device-sdk/SampleApp/src/SampleApplication.cpp
//68行目
// Default AVS endpoint to connect to.
static const std::string DEFAULT_ENDPOINT("https://avs-alexa-fe.amazon.com");

アプリ実行中のメニューに日本語切り替えを追加

/sdk-folder/sdk-source/avs-device-sdk/SampleApp/src/UIManager.cpp
//89行目のこのメニューに日本語を追加し、英語と入れ替える
static const std::string LOCALE_MESSAGE =
    "+----------------------------------------------------------------------------+n"
    "|                          Language Options:                                 |n"
    "|                                                                            |n"
    "| Press '1' followed by Enter to change the language to US English.          |n"
    "| Press '2' followed by Enter to change the language to UK English.          |n"
    "| Press '3' followed by Enter to change the language to German.              |n"
   "+----------------------------------------------------------------------------+n";
//を、次の内容に変更する
static const std::string LOCALE_MESSAGE =
    "+----------------------------------------------------------------------------+n"
    "|                          Language Options:                                 |n"
    "|                                                                            |n"
    "| Press '1' followed by Enter to change the language to Japan.               |n"
    "| Press '2' followed by Enter to change the language to UK English.          |n"
    "| Press '3' followed by Enter to change the language to German.              |n"
    "| Press '4' followed by Enter to change the language to US English.          |n"
    "+----------------------------------------------------------------------------+n";

メニューの変更をテーブルにも反映

/sdk-folder/sdk-source/avs-device-sdk/SampleApp/src/UIManager.cpp
//44行目 日本語を追加し、英語と入れ替える
static const std::unordered_map<char, std::string> LOCALE_VALUES({{'1', "ja-JP"}, {'2', "en-GB"}, {'3', "de-DE"},{'4', "en-US"}});

2.4 AVSのSDKをビルド
まずはcmakeを走らせます。
ウェイクワードをONやgstreamer許可なのをオプションとして設定しています。

cd /home/pi/sdk-folder/sdk-build && cmake /home/pi/sdk-folder/sdk-source/avs-device-sdk -DSENSORY_KEY_WORD_DETECTOR=ON -DSENSORY_KEY_WORD_DETECTOR_LIB_PATH=/home/pi/sdk-folder/third-party/alexa-rpi/lib/libsnsr.a -DSENSORY_KEY_WORD_DETECTOR_INCLUDE_DIR=/home/pi/sdk-folder/third-party/alexa-rpi/include -DGSTREAMER_MEDIA_PLAYER=ON -DPORTAUDIO=ON -DPORTAUDIO_LIB_PATH=/home/pi/sdk-folder/third-party/portaudio/lib/.libs/libportaudio.a -DPORTAUDIO_INCLUDE_DIR=/home/pi/sdk-folder/third-party/portaudio/include

そしてmake
make SampleApp -j3

makeのjオプションは並行処理の数で多い方がより高速になるがリスクもあるので、とりあえず3程度で。安全なのはもちろんjオプションなし。

3. AVSの認証
3.1 プロダクトの登録
amazonへサインインして今回のプロダクトを登録します。
入力方法はこちらを参考に(https://github.com/alexa/alexa-avs-sample-app/wiki/Create-Security-Profile)

この登録で設定したProductID,ClientID,ClientSecretの3つは認証時に必ず必要になるよ!

3.2 AlexaClientSDKConfig.jsonの設定
これは認証実行時に必要な情報を定義する設定ファイルです

場所:/home/pi/sdk-folder/sdk-build/Integration
ファイル名:AlexaClientSDKConfig.json

AlexaClientConfig.json
{
    "authDelegate":{
        "clientSecret”:”Amazonへ設定したClientSecret”,
        "deviceSerialNumber":"123456,(適当でいい)
        "refreshToken”:”{デフォルトの状態にしておく、認証後に自動的に追加されるため}”,
        "clientId”:”Amazonへ設定したclientID”,
        "productId”:”Amazonへ設定したproductID”
    },
    "alertsCapabilityAgent":{
        "databaseFilePath":"/home/pi/sdk-folder/application-necessities/alerts.db",
        "alarmSoundFilePath":"/home/pi/sdk-folder/application-necessities/sound-files/med_system_alerts_melodic_01._TTH_.mp3",
        "alarmShortSoundFilePath":"/home/pi/sdk-folder/application-necessities/sound-files/med_system_alerts_melodic_01_short._TTH_.wav",
        "timerSoundFilePath":"/home/pi/sdk-folder/application-necessities/sound-files/med_system_alerts_melodic_02._TTH_.mp3",
        "timerShortSoundFilePath":"/home/pi/sdk-folder/application-necessities/sound-files/med_system_alerts_melodic_02_short._TTH_.wav"
    },
    "settings":{
        "databaseFilePath":"/home/pi/sdk-folder/application-necessities/settings.db",
        "defaultAVSClientSettings":{
            "locale":"ja-JP"
        }
    },
    "certifiedSender":{
        "databaseFilePath":"/home/pi/sdk-folder/application-necessities/certifiedSender.db
    }
}

3.3 認証
認証用プログラムを起動、3000ポートで待ち受けます
cd /home/pi/sdk-folder/sdk-build && python AuthServer/AuthServer.py

ブラウザを立ち上げhttp://localhost:3000へアクセス
Developer用のアカウントを聞かれるのでログイン
ログインが成功するとWindowを閉じろというメッセージが出て認証完了

※AlexaClientSDKConfig.jsonのrefreshTokenに新たなトークンが追加されていることが確認できます。

4 実行
4.1 マイクのテスト
テストの前にpiのカレントに設定ファイルを用意します。

ファイル名:.asoundrc
場所:ユーザーカレントディレクトリ

.asoundrc
pcm.!default{
    type asym
    playback.pcm {
        type plug
        slave.pcm "hw:0,0"
    }
    capture.pcm {
        type plug
        slave.pcm "hw:1,0"
    }
}

※playbackは再生、captureは入力の設定ブロックになっている。
slave.pcmの値hw:はa,b a=カード b=デバイス番号となっている。
ラズパイに差したスピーカーとマイクの値を確認し設定すること。
aplay -lで確認できる。

4.2 soxのインストール
音声の加工や編集が出来るコマンドですー。
sudo apt-get install sox -y

4.3 マイクとスピーカーの入出力テスト
rec test.wav

実行したら何か話してみよう。
声に合わせて入力ボリュームが変化している様子がコマンドから分かると思う。
それが確認できたらControl+Cで終了

`aplay /usr/share/sounds/alsa/Front_Center.wav’

「フロントセンタ〜」っていう女性の声が聞こえたらOK
もし聞こえない場合はスピーカーの接続種別により次の設定を行って下さい

sudo amixer cset numid=3 x

xは接続の種類です。
0:自動判別
1:ライン入力(3.5mm)
2:HDMI
自動判別の場合はHDMIが優先されます。

4.4 いよいよAlexa起動
srcに移動
cd /home/pi/sdk-folder/sdk-build/SampleApp/src
SampleAPPを起動

TZ=UTC ./SampleApp /home/pi/sdk-folder/sdk-build/Integration/AlexaClientSDKConfig.json /home/pi/sdk-folder/third-party/alexa-rpi/models

SampleAPPの引数に設定ファイルであるAlexaClientSDKConfig.jsonとmodelsを指定

4.5 「Alexa! こんにちは!」と話しかけてみよう。
あとは、スマホにAlexa APPを追加するかここから好きなスキルを追加してみましょう。

カスタムスキルの作り方(Alexa Skills KitとLambda)はいろんな人が書いてるのでそちらを見てください。

続きを読む

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

続きを読む

AWS Lambda で Angular アプリを Server Side Rendering してみる

AWS Lambda Advent Calendar 2017 の8日目です。

前書き

Server Side Rendering (SSR) は、いわゆる SPA (Single Page Application) において、ブラウザー上(クライアントサイド)で動的に生成される DOM と同等の内容を持つ HTML をサーバーサイドで出力するための仕組みです。

React、Vue 等のモダンなフロントエンドフレームワークに軒並み搭載されつつあるこの機能ですが、 Angular にもバージョン2以降 Universal という SSR の仕組みがあります。

この仕組みを Lambda の上で動かして、 API Gateway 経由で見れるようにしてみる記事です。

SSR すると何がうれしいの?

以下のようなメリットがあるとされています

  • Web クローラーへの対応(SEO)

    • 検索エンジンのクローラーは HTML の内容を解釈してその内容をインデックスしますが、クライアントサイドで動的に変更された状態までは再現できません。そのため SSR に対応していない SPA では、コンテンツをクローラーに読み込ませるのが困難です。検索エンジンではないですが、 OGPTwitter Card もクローラーで読み込んだ情報を元に表示していますね
  • モバイル、低スペックデバイスでのパフォーマンス改善
    • いくつかのデバイスは JavaScript の実行パフォーマンスが非常に低かったり、そもそも実行できなかったりします。 SSR された HTML があれば、そのようなデバイスでもコンテンツを全く見られないという事態を避けられます
  • 最初のページを素早く表示する

どうやって Lambda で SSR するか

Angular Universal は各 Web サーバーフレームワーク用のミドルウェアとして実装されており、現時点では Express, Hapi, ASP.NET 用のエンジンがリリースされています。

他方 Lambda には AWS が開発した、既存の Express アプリを Lambda 上で動かすための aws-serverless-express があります。

今回は Angular Express Engineaws-serverless-express を組み合わせて Lambda 上で Angular アプリを SSR してみます。

やってみる

公式の Universal チュートリアルをなぞりつつ、 Lambda 対応に必要な部分をフォローしていきます。

Router を使っていないと面白くないので、公式の Angular チュートリアルである Tour of Heroes の完成段階 をいじって SSR 対応にしてみましょう。

チュートリアルのコードをまず動かす

コードを DL して展開、 yarn で依存物をインストールします。

ついでに git init して、 .gitignore も追加しておきましょう。

curl -LO https://angular.io/generated/zips/toh-pt6/toh-pt6.zip
unzip toh-pt6 -d toh-pt6-ssr-lambda
cd toh-pt6-ssr-lambda
yarn

ng serve で動くことを確認しておきます。

yarn run ng serve --open

SSR に必要なものをインストール

yarn add @angular/platform-server @nguniversal/express-engine @nguniversal/module-map-ngfactory-loader express
yarn add --dev @types/express

ルートモジュールを SSR 用に改変

src/app/app.module.ts
import { NgModule, Inject, PLATFORM_ID, APP_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

// ...

@NgModule({
  imports: [
    BrowserModule.withServerTransition({appId: 'toh-pt6-ssr-lambda'}),

// ...

export class AppModule {
  constructor(
    @Inject(PLATFORM_ID) private platformId: Object,
    @Inject(APP_ID) private appId: string,
  ) {
    const platform = isPlatformBrowser(platformId) ? 'browser' : 'server';

    console.log({platform, appId});
  }
}

サーバー用ルートモジュールを追加

yarn run ng generate module app-server --flat true
src/app/app-server.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ServerModule } from '@angular/platform-server';
import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';

@NgModule({
  imports: [
    CommonModule,
    AppModule,
    ServerModule,
    ModuleMapLoaderModule,
  ],
  declarations: [],
  bootstrap: [AppComponent],
})
export class AppServerModule {}

サーバー用ブートストラップローダーを追加

src/main.server.ts
export { AppServerModule } from './app/app.server.module';

サーバーのコードを実装

server/index.ts
import 'zone.js/dist/zone-node';
import 'reflect-metadata';

import {enableProdMode} from '@angular/core';

import * as express from 'express';
import {join} from 'path';

// Faster server renders w/ Prod mode (dev mode never needed)
enableProdMode();

// Express server
export const app = express();

// * NOTE :: leave this as require() since this file is built Dynamically from webpack
const {AppServerModuleNgFactory, LAZY_MODULE_MAP} = require('../dist/serverApp/main.bundle');

// Express Engine
import {ngExpressEngine} from '@nguniversal/express-engine';
// Import module map for lazy loading
import {provideModuleMap} from '@nguniversal/module-map-ngfactory-loader';

app.engine('html', ngExpressEngine({
  bootstrap: AppServerModuleNgFactory,

  providers: [
    provideModuleMap(LAZY_MODULE_MAP)
  ],
}));

app.set('view engine', 'html');
app.set('views', join(process.cwd(), 'dist', 'browserApp'));

// Server static files from /browser
app.get('*.*', express.static(join(process.cwd(), 'dist', 'browserApp')));

// All regular routes use the Universal engine
app.get('*', (req, res) => {
  res.render(join(process.cwd(), 'dist', 'browserApp', 'index.html'), {req});
});
server/start.ts
import {app} from '.';

const PORT = process.env.PORT || 4000;

app.listen(PORT, () => {
  console.log(`Node server listening on http://localhost:${PORT}`);
});

チュートリアルからの変更点: あとで Lambda で使い回すのでこのコードでは app.listen() せずに単に export しておき、スタート用のファイルは別に用意します。ファイルの置き場所も若干変更しています。

サーバー用のビルド設定を追加

.angular-cli.json
// ...
  "apps": [
    {
      "platform": "browser",
      "root": "src",
      "outDir": "dist/browser",
// ...
    {
      "platform": "server",
      "root": "src",
      "outDir": "dist/server",
      "assets": [
        "assets",
        "favicon.ico"
      ],
      "index": "index.html",
      "main": "main.server.ts",
      "test": "test.ts",
      "tsconfig": "tsconfig.server.json",
      "testTsconfig": "tsconfig.spec.json",
      "prefix": "app",
      "styles": [
        "styles.css"
      ],
      "scripts": [],
      "environmentSource": "environments/environment.ts",
      "environments": {
        "dev": "environments/environment.ts",
        "prod": "environments/environment.prod.ts"
      }
    }
src/tsconfig.server.json
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "outDir": "../out-tsc/app",
    "baseUrl": "./",
    "module": "commonjs",
    "types": []
  },
  "exclude": [
    "test.ts",
    "**/*.spec.ts"
  ],
  "angularCompilerOptions": {
    "entryModule": "app/app-server.module#AppServerModule"
  }
}

.angular-cli.json にはクライアント用のビルド設定しか書かれていないので、ここにサーバーサイド用アプリのビルド設定を追加します。 outDir が被らないように変えておきます。

ng build では「サーバーサイド用の Angular アプリのビルド」までは面倒を見てくれますが、サーバー自体のコードのビルドは自力でやる必要があるので、もろもろ追加します。

yarn add --dev awesome-typescript-loader webpack copy-webpack-plugin concurrently
server/webpack.config.js
const {join} = require('path');
const {ContextReplacementPlugin} = require('webpack');
const CopyWebpackPlugin = require("copy-webpack-plugin");

module.exports = {
  entry: {
    server: join(__dirname, 'index.ts'),
    start: join(__dirname, 'start.ts'),
  },

  resolve: { extensions: ['.js', '.ts'] },
  target: 'node',
  externals: [/(node_modules|main\..*\.js)/],
  output: {
    path: join(__dirname, '..', 'dist', 'server'),
    filename: '[name].js'
  },
  module: {
    rules: [{ test: /\.ts$/, loader: 'awesome-typescript-loader' }]
  },
  plugins: [
    new CopyWebpackPlugin([
      {from: "dist/browserApp/**/*"},
      {from: "dist/serverApp/**/*"},
    ]),

    // Temporary Fix for issue: https://github.com/angular/angular/issues/11580
    // for 'WARNING Critical dependency: the request of a dependency is an expression'
    new ContextReplacementPlugin(
      /(.+)?angular(\\|\/)core(.+)?/,
      join(__dirname, '..', 'src'), // location of your src
      {} // a map of your routes
    ),
    new ContextReplacementPlugin(
      /(.+)?express(\\|\/)(.+)?/,
      join(__dirname, '..', 'src'),
      {}
    )
  ]
};

サーバーの実装上の都合で (クライアント用の Angular ビルド + サーバー用の Angular ビルド) → サーバーのビルド という手順を踏む必要があるので、一連のビルド用スクリプトを追加します。

package.json
    "build:prod": "concurrently --names 'browser,server' 'ng build --prod --progress false --base-href /prod/ --app 0' 'ng build --prod --progress false --base-href /prod/ --app 1 --output-hashing false'",
    "prebuild:server": "npm run build:prod",
    "build:server": "webpack --config server/webpack.config.js",
    "start:server": "node dist/server/start.js"

--base-href を指定しているのは、後で API Gateway の仕様との整合性をとるためです。

ローカルでサーバーを動かしてみる

yarn run build:server
yarn run start:server

http://localhost:4000/prod/detail/14 あたりにアクセスして、 SSR されているか確認してみます。

Tour_of_Heroes.png

最初のリクエストに対するレスポンスで、 Angular が生成した HTML が返ってきていることがわかります。チュートリアル第1章で pipe を使って大文字にしたところもちゃんと再現されていますね。

普通に ng serve した場合はこんな感じのはずです。

Tour_of_Heroes.png

Lambda にデプロイする

おなじみの Serverless Framework を使います。いつもありがとうございます。

ここで aws-serverless-express も追加しましょう。

yarn add aws-serverless-express
yarn add --dev @types/aws-serverless-express serverless serverless-webpack

Lambda 用のエントリーポイントを追加します。

lambda/index.ts

import {createServer, proxy} from 'aws-serverless-express';

import {app} from '../server';

export default (event, context) => proxy(createServer(app), event, context);

Lambda (serverless-webpack) 用のビルド設定を追加します。さっきのやつとほぼ同じですが、 libraryTarget: "commonjs" がないと動かないようです。

lambda/webpack.config.js
const {join} = require('path');
const {ContextReplacementPlugin} = require('webpack');
const CopyWebpackPlugin = require("copy-webpack-plugin");
const slsw = require('serverless-webpack');

module.exports = {
  entry: slsw.lib.entries,
  resolve: { extensions: ['.js', '.ts'] },
  target: 'node',
  externals: [/(node_modules|main\..*\.js)/],
  output: {
    libraryTarget: "commonjs",
    path: join(__dirname, '..', 'dist', 'lambda'),
    filename: '[name].js'
  },
  module: {
    rules: [{ test: /\.ts$/, loader: 'awesome-typescript-loader' }]
  },
  plugins: [
    new CopyWebpackPlugin([
      {from: "dist/browserApp/**/*"},
      {from: "dist/serverApp/**/*"},
    ]),

    // Temporary Fix for issue: https://github.com/angular/angular/issues/11580
    // for 'WARNING Critical dependency: the request of a dependency is an expression'
    new ContextReplacementPlugin(
      /(.+)?angular(\\|\/)core(.+)?/,
      join(__dirname, '..', 'src'), // location of your src
      {} // a map of your routes
    ),
    new ContextReplacementPlugin(
      /(.+)?express(\\|\/)(.+)?/,
      join(__dirname, '..', 'src'),
      {}
    )
  ]
};

Serverless の設定ファイルを追加します。あらゆるパスへの GET を Lambda にルーティングするように設定します。

serverless.yml
service: toh-pt6-ssr-lambda
provider:
  name: aws
  runtime: nodejs6.10
  region: ${env:AWS_REGION}
  memorySize: 128
plugins:
- serverless-webpack
custom:
  webpack: lambda/webpack.config.js
  webpackIncludeModules: true
functions:
  main:
    handler: lambda/index.default
    events:
    - http:
        path: /
        method: get
    - http:
        path: "{proxy+}"
        method: get

Serverless をインストールした状態でビルドしようとすると以下のようなエラーが出るので、とりあえず tsconfig.json を変更して回避しておきます。

ERROR in [at-loader] ./node_modules/@types/graphql/subscription/subscribe.d.ts:17:4
    TS2304: Cannot find name 'AsyncIterator'.

ERROR in [at-loader] ./node_modules/@types/graphql/subscription/subscribe.d.ts:29:4
    TS2304: Cannot find name 'AsyncIterable'.
tsconfig.json
    "lib": [
      "es2017",
      "dom",
      "esnext.asynciterable"
    ]

Lambda 用のデプロイスクリプトを追加します。

package.json
    "predeploy": "npm run build:prod",
    "deploy": "serverless deploy"

で、ようやく Lambda のデプロイです。

yarn run deploy

うまくいけば Serverless によって各種 AWS リソースが作られ、 API Gateway のエンドポイントが出力されるはずです。

API Gateway にアクセスして SSR されているか見てみる

先程と同様に確認してみます。

Tour_of_Heroes.png

よさげ。

今後の課題

Lambda + API Gateway で SSR することができました。しかし、いろいろと課題はまだありそうです。

HTTP ステータスコード問題

現状のコードでは固定的に 200 を返しているため、ありえないパスにリクエストが来てもクローラー的には OK と解釈されてしまいます。本来なら 404 などを適切に返すべきです。

HTML 以外をどうやって動的に返すか

一般的なサイトでは sitemap.xml など動的な内容かつ HTML ではないものも返す必要があります。これを Angular でできるかどうか、調べる必要がありそうです。

Express なくせるんじゃね?

今回は Angular -> Universal + Express Engine -> Express -> aws-serverless-express -> Lambda (API Gateway Proxy Integration) という繋ぎ方をしました。

これを Angular -> Universal + [何か] -> Lambda (API Gateway Proxy Integration) にできたら構成要素が減らせていい感じですよね。

renderModuleFactory などを自力で叩く実装ができれば、これが実現できるのかもしれません。

今回作ったコード

参考

続きを読む

GoでURLからファイルを取得してS3へ保存

一度ファイルを作成してから、S3へ配置します。
(ストリームのままS3へ保存する方法教えてください!)

// download file
response, err := http.Get(url)
if err != nil {
  return fmt.Errorf("download error: %s - %s", url, err)
}
defer response.Body.Close()

// create file
output, err := os.Create(filename)
if err != nil {
  return fmt.Errorf("file create error: %s - %s", filename, err)
}
defer output.Close()

// copy
n, err := io.Copy(output, response.Body)
if err != nil {
  return fmt.Errorf("file copy error: %s - %s", filename, err)
}

cli := s3.New(session.New(), aws.NewConfig().WithRegion("ap-northeast-1"))

input := &s3.PutObjectInput{
  Body:   output,
  Bucket: aws.String("bucket-name"),
  Key:    aws.String("filename"),
}
if _, err := cli.PutObject(input); err != nil {
  return fmt.Errorf("put object error: %s", err)
}

// delete file
if err := os.Remove(filename); err != nil {
  return fmt.Errorf("file delete error: %s - %s", filename, err)
}

fmt.Printf("download from %s -> %s (%dbytes)n", url, filename, n)

続きを読む

JavaScriptでAWS CloudFrontのキャッシュを削除(Invalidate)する

概要

  • メモ
  • AWS-SDKを利用(初めて使った)

コード

package.json
{
    "dependencies": {
        "aws-sdk": ">= 2.0.9",
        "node-uuid": ">= 1.4.1"
    }
}

これでnode install

実際にキャッシュをinvalidateするJavaScriptは以下

invalidate.js
// Load the SDK and UUID
var AWS = require('aws-sdk');
var uuid = require('node-uuid');

var cloudfront = new AWS.CloudFront({
    apiVersion: '2017-03-25',
    accessKeyId: YOUR_AWS_ACCESS_KEY,
    secretAccessKey: YOUR_AWS_SECRET_KEY,
});

var timestamp = new Date();
var string_timestamp = String(timestamp.getTime());

var invalidate_items = ['/* Subject to invalidate */',];
var item_count = invalidate_items.length

var params = {
  DistributionId: YOUR_CLOUDFRONT_ID,
  InvalidationBatch: {
    CallerReference: string_timestamp,
    Paths: {
      Quantity: item_count,
      Items: invalidate_items
    }
  }
};

cloudfront.createInvalidation(params, function(err, data) {
  if (err) console.log(err, err.stack);
  else     console.log(data);
});

invalidate_itemというリストにキャッシュを削除したい対象を格納し、parmsのItemsに渡す
キャッシュ削除対象の個数をitem_countに渡して、それをparamsのQuantityに渡す
accessKeyId、secretAccessKey、DistributionIdについてはそれぞれ自分の環境のやつを入れる
あと、apiVersionについては2017年12月現在のものです

とりあえず、これで node invalidate.js すればcloud frontへキャッシュ削除してくれる
結果はこんな感じで返ってくる

{ Location: 'https://cloudfront.amazonaws.com/2017-03-25/distribution/{YOUR_CLOUDFRONT_ID}/invalidation/{invalidation_id}',
  Invalidation: 
   { Id: '{invalidation_id}',
     Status: 'InProgress',
     CreateTime: 2017-12-06T04:40:16.628Z,
     InvalidationBatch: { Paths: [Object], CallerReference: '{string_timestamp}' } } }

まあ、これだけだと実際に運用することはできないのだけど、とりあえず、動いてよかったですね、という感じです

参考

続きを読む

AWS(EC2) で Python3 + Flask を Apache で動かす(mod_wsgi?)

はじめに

簡単なWebサービス新しく立ち上げるにあたり、勉強を兼ねて Python を使うことにしたので、その環境構築用のメモとして残す。ほとんど、他の記事を寄せ集めただけなのだけど、mod_wsgiのインストールで少し手間取ったので念のため。

参考:PythonのWebフレームワーク4種比較
https://qiita.com/Morio/items/8db3c9b3ba7f2c71ef27

AWS (EC2) + Apache2

下記のサイトを参考にしてテスト用のEC2サーバのインスタンスを作成後、Apache2 をインストール・起動します。なお、現時点(2017/12/06)でのバージョンは、2.2.34だった。

参考:AWS EC2でWebサーバーを構築してみる
https://qiita.com/Arashi/items/629aaed33401b8f2265c

Python3 + pip3

下記のサイトを参考にして Python3 をインストールし、pip を設定。現時点(2017/12/06)では、python3.6がリリースされているので、3.5ではなく3.6をインストール。

参考:Amazon Linux (EC2)上でPython3と…
https://qiita.com/KeijiYONEDA/items/f9cf37cfc359aa893797
※「3.5」は「3.6」に、「35」は「36」に読み替え

実際に実行したコマンド.
sudo yum install python36-devel python36-libs python36-setuptools
sudo /usr/bin/easy_install-3.6 pip

Flask + mod-wsgi

Flask のインストールは簡単。下記のサイトの最初の5分だけを実行すれば、Hello Flask! を表示するWebサイトは完成!

参考:PythonのFlaskを初めて触ってから30分で…
https://qiita.com/yoshizaki_kkgk/items/3cd785b4a670deec0685
※ 環境構築からHello Flask!まで(5分)だけを実行

・・・でも今回は Apacheサーバを利用するので、mod_swgi のインストールが必要となる。だけど、上記サイトの次の10分以降に書いてあるようにソースから configure・make なんて 前時代的 というか 面倒くさい というか、難しいことは避けたいので、pipに頼ることにする

pip3 install mod-wsgi

このコマンドでインストールできそうなのだけど、下記のエラーが出て前に進まなくなった。apxs って何さ?

Collecting mod_wsgi
  Using cached mod_wsgi-4.5.22.tar.gz
    Complete output from command python setup.py egg_info:
    Traceback (most recent call last):
      File "<string>", line 1, in <module>
      File "/tmp/pip-build-4lb9c3ll/mod-wsgi/setup.py", line 301, in <module>
        INCLUDEDIR = get_apxs_config('INCLUDEDIR')
      File "/tmp/pip-build-4lb9c3ll/mod-wsgi/setup.py", line 273, in get_apxs_config
        stdout=subprocess.PIPE, stderr=subprocess.PIPE)
      File "/usr/lib64/python3.6/subprocess.py", line 707, in __init__
        restore_signals, start_new_session)
      File "/usr/lib64/python3.6/subprocess.py", line 1333, in _execute_child
        raise child_exception_type(errno_num, err_msg)
    FileNotFoundError: [Errno 2] No such file or directory: 'apxs'

apxs = Apache extension tool

ググってみたら、apxs は「APache eXtenSion tool」のことだそうで、apache-dev に含まれるそうな。早速、以下のコマンドで apache-dev をインストール。しかし、省略難しすぎ。

sudo yum install httpd-devel

これで大丈夫!と思ったけど、次は、gcc が入っていなくて怒られました。仕方ないので、gcc を yum 経由でインストール。

sudo yum install gcc
sudo /usr/local/bin/pip3 install mod_wsgi

デプロイ

必要なものは揃った!ってことで、Apache の設定とサンプル用のサイトを作ってみる。

サイトの作成

Hello Flask!をそのまま流用させていただく。

/var/www/flask/app.py
# coding: utf-8
from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello():
    return "Hello, Flask!"

if __name__ == "__main__":
    app.run()

swgi ファイル

Apache というか、mod_swgiに読み込ませるファイルを作成

/var/www/flask/adapter.wsgi
# coding: utf-8
import sys
sys.path.insert(0, '/var/www/flask')

from app import app as application

Apacheの設定

Apache の conf.d に設定ファイルを追加し、app.py と adapter.wsgi が有効になるように設定。

/etc/httpd/conf.d/flask.conf
# /etc/httpd/modules には配置されないので指定が必要
LoadModule wsgi_module /usr/local/lib64/python3.6/site-packages/mod_wsgi/server/mod_wsgi-py36.cpython-36m-x86_64-linux-gnu.so

<VirtualHost *:80>
  ServerName ec2-xxxxxxx.amazonaws.com:80
  DocumentRoot /var/www/flask
  WSGIScriptAlias / /var/www/flask/adapter.wsgi
  <Directory "/var/www/flask/">
    options Indexes FollowSymLinks +ExecCGI
  </Directory>
</VirtualHost>

まとめ

python3、pip 辺りは他にも利用するだろうから、すでにインストールされている場合が多そう。なので、実質面倒なのは Apache(mod_wsgi)の設定周りかな。パッケージ化が進んでいると思っていたし、yum / pip といった便利なものがあるにも関わらず、gccが必要になるとは思わなかった。

次は、flask + DataBase + Template Engine ってとこかな。

続きを読む