Angular+Serverless Framework+AWSな構成のサーバレスWebアプリにAmplify+API Gateway Lambda AuthorizerでCognitoユーザ認証を組み込んでみた

これはServerless Advent Calendar 2018の15日目です。

インフラ構築Backend APIFrontend SPAと実装してきたサーバレスWebアプリのサンプルにAWS AmplifyAWS API Gateway Lambda Authorizerを使ってCognitoユーザ認証を組み込んでみました。

今回のコード

それぞれ記事時点のコードにタグを打ってあります。

実装の概要

詳細はGitHubにコードがありますのでここでは概要だけ記していきます。

f:id:nihma:20181215143641p:plain

f:id:nihma:20181215162102p:plain

Frontend SPA

AngularでAWS Amplifyを導入してSignUp/SignIn/SignOutページの作成とTodo操作のログイン必須化対応、APIリクエストへ認証トークン追加を行いました。 参考にした記事や情報は下記あたりです。

dev.classmethod.jp dev.classmethod.jp dev.classmethod.jp

今回はローカルのDocker環境を外部サービスに依存せずスタンドアロンでも動作できるようにしたいと思いました。*1 そのため環境変数USER_POOL_IDで指定するCognitoプールIDの内容により認証スキップできるようにしました。スキップしたい時の指定はDummyとしました。

Cognito認証のためのAmplify設定

まず環境変数USER_POOL_IDUSER_POOL_CLIENT_IDを追加します。*2

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/dockers/docker-compose.yml#L10

    environment:
...
      USER_POOL_ID: 'Dummy'
      USER_POOL_CLIENT_ID: 'Dummy'

そしてenvironment.tsにAmplifyの設定を追加します。environment.tsをデプロイ時に動的生成するpre_build.shです。

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/bin/pre_build.sh#L21

  amplify: {
    Auth: {
      region: 'ap-northeast-1',
      userPoolId: '${USER_POOL_ID}',
      userPoolWebClientId: '${USER_POOL_CLIENT_ID}'
    }
  },
...
  localstorageBaseKey: 'CognitoIdentityServiceProvider.${USER_POOL_CLIENT_ID}.'

Cognito認証するAuthServiceと認証しないMockAuthServiceの切り替えはAppModuleのprovidersで行います。

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/src/app/app.module.ts#L72

  providers: [
    MockWebApiService,
    {
      provide: AuthService,
      useClass: environment.amplify.Auth.userPoolId === 'Dummy' ? MockAuthService : AuthService
    }
  ],

AuthServiceMockAuthServiceの内容は下記です。共にinterfaceのIAuthServiceをimplementsします。

f:id:nihma:20181214224717p:plain

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/src/app/auth/auth.service.ts

import { Injectable } from '@angular/core';
import { Router } from '@angular/router';
import { BehaviorSubject, from, Observable, of } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import Amplify, { Auth } from 'aws-amplify';
import { environment } from './../../environments/environment';

export interface IAuthService {
  signUp(email: string, password: string): Observable<any>;
  confirmSignUp(email: string, code: string): Observable<any>;
  signIn(email: string, password: string): Observable<any>;
  getData(): Observable<any>;
  getIdToken(): Promise<string>;
  isAuthenticated(): Observable<boolean>;
  signOut(): void;
}

@Injectable({
  providedIn: 'root'
})
export class AuthService implements IAuthService {
  public loggedIn: BehaviorSubject<boolean>;
  password!: String;

  constructor(
    private router: Router
  ) {
    Amplify.configure(environment.amplify);
    this.loggedIn = new BehaviorSubject<boolean>(false);
  }

  public signUp(email: string, password: string): Observable<any> {
    this.password = password;

    return from(Auth.signUp(email, password, email));
  }

  public confirmSignUp(email: string, code: string): Observable<any> {
    return from(Auth.confirmSignUp(email, code));
  }

  public signIn(email: string, password: string): Observable<any> {
    return from(Auth.signIn(email, password))
      .pipe(
        tap(() => this.loggedIn.next(true))
      );
  }

  public getData(): Observable<any> {
    return from(Auth.currentAuthenticatedUser());
  }

  public getIdToken(): Promise<string> {
    return Auth.currentSession()
      .then(session => {
        return session.getIdToken()
          .getJwtToken();
      });
  }

  public isAuthenticated(): Observable<boolean> {
    return from(Auth.currentAuthenticatedUser())
      .pipe(
        map(_result => {
          this.loggedIn.next(true);

          return true;
        }),
        catchError(_error => {
          this.loggedIn.next(false);

          return of(false);
        })
      );
  }

  public signOut(): void {
    from(Auth.signOut())
      .subscribe(
        _result => {
          this.loggedIn.next(false);
          this.router.navigate(['/login']);
        },
        error => console.log(error)
      );
  }
}

@Injectable({
  providedIn: 'root'
})
export class MockAuthService implements IAuthService {
  public loggedIn: BehaviorSubject<boolean>;
  password!: String;
  idSignIn = false;

  constructor(
    private router: Router
  ) {
    this.loggedIn = new BehaviorSubject<boolean>(false);
  }

  public signUp(_email: string, password: string): Observable<any> {
    this.password = password;

    return of(true);
  }

  public confirmSignUp(_email: string, _code: string): Observable<any> {
    return of(true);
  }

  public signIn(_email: string, _password: string): Observable<any> {
    this.idSignIn = true;

    return of([])
      .pipe(
        tap(() => this.loggedIn.next(true))
      );
  }

  public getData(): Observable<any> {
    return of([]);
  }

  public getIdToken(): Promise<string> {
    return Promise.resolve('dummyToken');
  }

  public isAuthenticated(): Observable<boolean> {
    this.loggedIn.next(this.idSignIn);

    return of(this.idSignIn);
  }

  public signOut(): void {
    this.idSignIn = false;
    this.loggedIn.next(this.idSignIn);
    this.router.navigate(['/login']);
  }
}

SignUp/SignIn/SignOutページ

参考にした記事とほぼ同じです。

https://github.com/nihemak/aws-sls-spa-sample-web/tree/v0.0.2/src/app/component/signup https://github.com/nihemak/aws-sls-spa-sample-web/tree/v0.0.2/src/app/component/login

Todoのログイン前提化

Todoの操作をログイン前提にするためのAuthGuardは下記です。

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/src/app/auth/auth.guard.ts

import { Injectable } from '@angular/core';
import { ActivatedRouteSnapshot, CanActivate, Router, RouterStateSnapshot } from '@angular/router';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { AuthService } from './auth.service';

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {
  constructor(
    private router: Router,
    private auth: AuthService
  ) { }

  canActivate(
    _next: ActivatedRouteSnapshot,
    _state: RouterStateSnapshot): Observable<boolean> | Promise<boolean> | boolean {
      return this.auth.isAuthenticated()
        .pipe(
          tap(loggedIn => {
            if (!loggedIn) {
              this.router.navigate(['/login']);
            }
          })
        );
  }
}

これを使ってTodosComponentでガードを行います。

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/src/app/app-routing.module.ts#L8

const routes: Routes = [
  { path: '', redirectTo: '/todos', pathMatch: 'full' },
  { path: 'todos', component: TodosComponent, canActivate: [AuthGuard] },
  { path: 'login', component: LoginComponent },
  { path: 'signup', component: SignupComponent }
];

APIリクエストに認証トークンを指定

あとはTodoServiceでAPIリクエストに認証トークンを渡すためにhttpヘッダにAuthorizationを追加しました。

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/src/app/todo.service.ts#L18

      headers: new HttpHeaders({
        'Content-Type': 'application/json',
        Authorization: token
      })

そして、ngrxでトークンを引き継ぐためにActionのpayloadにトークンを追加します。

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/src/app/store/todo.ts#L29

export class FindAll implements TodoAction {
  readonly type = FIND_ALL;
  constructor(public payload: { token: string }) {}
}

TodosComponentでngrxのStoreをdispatchしている箇所をActionのpayload経由でトークン指定するよう変更します。

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/src/app/todos/todos.component.ts#L35

  getTodos(): void {
    this.authService.getIdToken()
      .then(token => {
        this.store.dispatch(new TodoFindAll({ token }));
      });
  }

あとはEffectでActionのpayloadからトークンを取り出してTodoServiceに指定するようにします。

https://github.com/nihemak/aws-sls-spa-sample-web/blob/v0.0.2/src/app/store/todo.effect.ts#L40

    switchMap(action => {
      const { token } = action.payload;

      return this.todoService
        .getTodos(token)
        .pipe(
          map(todos => new FindAllSuccess({ todos })),
          catchError(error => of(new FindAllFail({ error })))
        );
    })

これでSignUpしてSignInし自分のTodoをCRUDできるようになりました。

Backend API

Serverless FrameworkでAPI Gateway Lambda Authorizerを使用してCognito認証とTodoのユーザ個別化の実装を行いました。 参考にした記事や情報は下記あたりです。

serverless.com docs.aws.amazon.com github.com Cognito JSON ウェブキートークンの署名を復号して検証する docs.aws.amazon.com github.com

Frontendと同様、BackendもローカルのDocker環境を外部サービスに依存せずスタンドアロンでも動作できるようにしたいと思いました。 具体的にはLambda Authorizerの中で環境変数USER_POOL_IDに指定するCognitoプールIDの内容を判断して認証スキップできるようにしました。これを実現したかったことがauthorizerにCognitoのarnを指定するのではなくLambda Authorizerにした理由です。スキップしたい時の指定はDummyとします。

Cognito認証のためのLambda Authorizer

まず環境変数USER_POOL_IDを追加します。*3

https://github.com/nihemak/aws-sls-spa-sample-api/blob/v0.0.3/dockers/docker-compose.yml#L19

    environment:
...
      USER_POOL_ID: 'Dummy'

https://github.com/nihemak/aws-sls-spa-sample-api/blob/v0.0.3/serverless.yml#L19

  environment:
 ...
    USER_POOL_ID: ${env:USER_POOL_ID}

そしてserverless.ymlでLambda Authorizerにauth.authorizeを指定します。

https://github.com/nihemak/aws-sls-spa-sample-api/blob/v0.0.3/serverless.yml#L25

functions:
  authorizer:
    handler: app/http/controllers/auth.authorize

https://github.com/nihemak/aws-sls-spa-sample-api/blob/v0.0.3/serverless.yml#L42

  list:
...
    events:
      - http:
...
          authorizer: authorizer

auth.authorizeは下記です。

https://github.com/nihemak/aws-sls-spa-sample-api/blob/v0.0.3/app/http/controllers/auth.ts

// https://github.com/serverless/examples/tree/master/aws-node-auth0-cognito-custom-authorizers-api
// https://github.com/awslabs/aws-apigateway-lambda-authorizer-blueprints/blob/master/blueprints/nodejs/index.js
import jwk from "jsonwebtoken";
import request from "request";
import jwkToPem from "jwk-to-pem";

const region = process.env.REGION;
const userPoolId = process.env.USER_POOL_ID;
const iss = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`;

// Generate policy to allow this user on this API:
const generatePolicy = (principalId: any, resource: any) => {
  const tmp = resource.split(":");
  const apiGatewayArnTmp = tmp[5].split("/");
  const awsAccountId = tmp[4];
  const region = tmp[3];
  const restApiId = apiGatewayArnTmp[0];
  const stage = apiGatewayArnTmp[1];
  const resourceArn = `arn:aws:execute-api:${region}:${awsAccountId}:${restApiId}/${stage}/*/*`;

  const policy = {
    principalId,
    policyDocument: {
      Version: "2012-10-17",
      Statement: [
        {
          Action: "execute-api:Invoke",
          Effect: "Allow",
          Resource: [resourceArn]
        }
      ]
    }
  };

  return policy;
};

export function authorize(event: any, _context: any, cb: any) {
  console.log("Auth function invoked");

  if (userPoolId === "Dummy") {
    console.log("Auth function dummy mode");
    cb(null, generatePolicy("dummySub", event.methodArn));
    return;
  }

  if (event.authorizationToken) {
    const token = event.authorizationToken;
    // Make a request to the iss + .well-known/jwks.json URL:
    request(
      { url: `${iss}/.well-known/jwks.json`, json: true },
      (error, response, body) => {
        if (error || response.statusCode !== 200) {
          console.log("Request error:", error);
          cb("Unauthorized");
        }
        const keys = body;
        // Based on the JSON of `jwks` create a Pem:
        const k = keys.keys[0];
        const jwkArray = {
          kty: k.kty,
          n: k.n,
          e: k.e
        };
        const pem = jwkToPem(jwkArray);

        // Verify the token:
        jwk.verify(token, pem, { issuer: iss }, (err, decoded: any) => {
          if (err) {
            console.log("Unauthorized user:", err.message);
            cb("Unauthorized");
          } else {
            cb(null, generatePolicy(decoded.sub, event.methodArn));
          }
        });
      }
    );
  } else {
    console.log("No authorizationToken found in the header.");
    cb("Unauthorized");
  }
}

TodosテーブルにuserId追加

Todoを各ユーザの所有物にするためにTodosテーブルにuserIdカラムを追加します。

https://github.com/nihemak/aws-sls-spa-sample-api/blob/v0.0.3/app/databases/Todos.ts#L35

@table("todos")
export class TodoRecord {
...
  @attribute()
  userId!: string;
...
}

userIdカラムでフィルタリングしてクエリするためにGSIとしてuserIdIdxを追加します。ここではDynamoDB Local用の設定を行います。*4

https://github.com/nihemak/aws-sls-spa-sample-api/blob/v0.0.3/app/databases/Todos.ts#L121

    const params = {
      AttributeDefinitions: [
        {
          AttributeName: "userId",
          AttributeType: "S"
        },
        {
          AttributeName: "createdAt",
          AttributeType: "N"
        }
      ],
      GlobalSecondaryIndexUpdates: [
        {
          Create: {
            IndexName: "userIdIdx",
            KeySchema: [
              {
                AttributeName: "userId",
                KeyType: "HASH"
              },
              {
                AttributeName: "createdAt",
                KeyType: "RANGE"
              }
            ],
            Projection: {
              ProjectionType: "ALL"
            },
            ProvisionedThroughput: {
              ReadCapacityUnits: 1,
              WriteCapacityUnits: 1
            }
          }
        }
      ],
      TableName: tableName
    };
    dynamodb.updateTable(params, function(err) {
      if (err) {
        throw err;
      }
    });

Todo操作の個別ユーザ化

httpリクエストからuserIdを取得できるようにします。

https://github.com/nihemak/aws-sls-spa-sample-api/blob/v0.0.3/app/adapters/http/requests/Todos.ts#L10

class TodoInput {
  protected _getAuthUserId(headers: { [name: string]: string }): string {
    let userId = headers["Authorization"];
    if (process.env.USER_POOL_ID !== "Dummy") {
      const decodedJwt: any = jwk.decode(userId, { complete: true });
      userId = decodedJwt.payload.sub;
    }
    return userId;
  }
}

そして取得したuserIdを使ってusecaseでTodosテーブル操作するようにします。

https://github.com/nihemak/aws-sls-spa-sample-api/blob/v0.0.3/app/usecases/implementations/Todos.ts#L45

  public async list(
    input: TodoListInput,
    output: TodoListOutput
  ): Promise<void> {
    const todos: Todo[] = await this.store.all(input.getAuthUserId());
    output.success(todos);
  }

これでCognito認証済みの場合のみユーザごとのTodoを操作できるようになりました。

インフラ構築

Backend APIにCognito指定

Backend APIをserverless deployするCodeBuildの環境変数にCognitoプールIDを追加します。

https://github.com/nihemak/aws-sls-spa-sample-terraform/blob/v0.0.3/modules/codebuild/api/production/main.tf#L49

resource "aws_codebuild_project" "api" {
...
    environment_variable {
      "name"  = "USER_POOL_ID"
      "value" = "${var.cognito_pool_id}"
    }

TodosテーブルにGSIのuserIdIdxを追加

DynamoDBのTodosテーブルにGSIのuserIdIdxを追加します。

https://github.com/nihemak/aws-sls-spa-sample-terraform/blob/v0.0.3/modules/dynamodb/main.tf

resource "aws_dynamodb_table" "todos" {
...
  attribute {
    name = "userId"
    type = "S"
  }

  attribute {
    name = "createdAt"
    type = "N"
  }

  global_secondary_index {
    name               = "userIdIdx"
    hash_key           = "userId"
    range_key          = "createdAt"
    write_capacity     = 1
    read_capacity      = 1
    projection_type    = "ALL"
  }

これでAWS環境で動作するようになりました。

まとめ

今回はサーバレスWebアプリのサンプルにAmplifyとAPI Gateway Lambda Authorizerを追加してCognito認証できるようにしてみました。色々と突貫で付け足したのでもろもろリファクタリングしたい気持ちです。

*1:依存してると簡単に動かして試したい時とかテストとかで煩わしいので

*2:ここの値を実際のCognitoの値にすればCognito認証を行えます。

*3:ここの値を実際のCognitoの値にすればCognito認証を行えます。

*4:実際のTodosテーブルのスキーマ変更はインフラ構築の実装で行います。

AWS CLIでCode4兄弟によるEC2+nginx+Laravelの継続的デプロイ環境を構築する

これはパーソルキャリア Advent Calendar 2018の4日目です。

AWS CLIを使ってEC2+nginx+Laravelな環境をCode4兄弟(CodeCommit, CodeBuild, CodeDeploy, CodePipeline)で継続的デプロイできるようにする手順をまとめてみました。

構築する環境

AWS CLIMacで実行し必要の都度、EC2にSSHして進めます。

Mac環境は以下です。

EC2環境は以下です。

まずEC2+nginx+Laravelの環境を構築します。そしてCode4兄弟を導入して継続的デプロイできる構成にしていきます。 ここでの継続的デプロイとは、CodeCommit/Laravelプロジェクトのmasterブランチに変更をpushしたら自動でEC2にデプロイされる仕組みのことです。

最終的にCode4兄弟の役割は下記のようにします。

  • CodeCommit : LaravelプロジェクトのGitリポジトリを管理する
  • CodeBuild : composer installを実施してvendorディレクトリを生成する
  • CodeDeploy : EC2インスタンスにLaravelプロジェクト設置しnginxに反映する
  • CodePipeline : CodeCommit, CodeBuild, CodeDeployを順に実施する

最終的な構成は下記です。

f:id:nihma:20181202183343p:plain

構築の手順

今回はMac環境に作業ディレクトリを作ってここで作業します。

$ mkdir work
$ cd work

最初のEC2+nginx+Laravelな環境を作成する

まずは前提となるVPC環境を作ります。

$ vpc=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16)
$ vpc_id=$(echo $vpc | jq -r ".Vpc.VpcId")
$ aws ec2 create-tags \
    --resources ${vpc_id} \
    --tags Key=Name,Value=test-laravel

インターネットゲートウェイVPCに紐付けます。

$ internet_gateway=$(aws ec2 create-internet-gateway)
$ internet_gateway_id=$( \
    echo $internet_gateway | jq -r ".InternetGateway.InternetGatewayId")
$ aws ec2 create-tags \
    --resources ${internet_gateway_id} \
    --tags Key=Name,Value=test-laravel
$ aws ec2 attach-internet-gateway \
    --internet-gateway-id ${internet_gateway_id} \
    --vpc-id ${vpc_id}

VPCにサブネットを作成します。

$ subnet=$( \
    aws ec2 create-subnet \
        --vpc-id ${vpc_id} \
        --cidr-block 10.0.0.0/24 \
        --availability-zone ap-northeast-1a)
$ subnet_id=$(echo $subnet | jq -r ".Subnet.SubnetId")
$ aws ec2 create-tags \
    --resources ${subnet_id} \
    --tags Key=Name,Value=test-laravel

ルートテーブルにインターネットゲートウェイのルーティング設定を追加します。

$ route_table_id=$( \
    aws ec2 describe-route-tables \
        --filters Name=vpc-id,Values=${vpc_id} \
             | jq -r ".RouteTables[].RouteTableId")
$ aws ec2 create-tags \
    --resources ${route_table_id} \
    --tags Key=Name,Value=test-laravel
$ aws ec2 create-route \
    --route-table-id ${route_table_id} \
    --destination-cidr-block 0.0.0.0/0 \
    --gateway-id ${internet_gateway_id}
$ aws ec2 associate-route-table \
    --route-table-id ${route_table_id} \
    --subnet-id ${subnet_id}

EC2用にSSH(22)とHTTP(80)を許可したセキュリティグループを作成します。

$ security_group=$( \
    aws ec2 create-security-group \
        --group-name test-laravel \
        --vpc-id ${vpc_id} \
        --description "test laravel")
$ security_group_id=$(echo $security_group | jq -r ".GroupId")
$ aws ec2 authorize-security-group-ingress \
    --group-id ${security_group_id} \
    --protocol tcp \
    --port 22 \
    --cidr 0.0.0.0/0
$ aws ec2 authorize-security-group-ingress \
    --group-id ${security_group_id} \
    --protocol tcp \
    --port 80 \
    --cidr 0.0.0.0/0

EC2インスタンスへのSSHアクセスの為にキーペア/pemを作成します。

$ key_pair=$(aws ec2 create-key-pair --key-name test-laravel)
$ echo $key_pair | jq -r ".KeyMaterial" > test-laravel.pem
$ chmod 400 test-laravel.pem

EC2インスタンスを作成します。使うAMIはAmazon Linux 2 AMI 2.0.20181114 x86_64 HVM gp2です。

$ aws ec2 describe-images \
    --image-ids ami-0a2de1c3b415889d2 \
    --region ap-northeast-1
{
    "Images": [
        {
            "ImageLocation": "amazon/amzn2-ami-hvm-2.0.20181114-x86_64-gp2",
            "ImageId": "ami-0a2de1c3b415889d2",
            "SriovNetSupport": "simple",
            "ImageType": "machine",
            "CreationDate": "2018-11-14T09:06:55.000Z",
            "RootDeviceType": "ebs",
            "VirtualizationType": "hvm",
            "Architecture": "x86_64",
            "ImageOwnerAlias": "amazon",
            "State": "available",
            "Hypervisor": "xen",
            "Description": "Amazon Linux 2 AMI 2.0.20181114 x86_64 HVM gp2",
            "RootDeviceName": "/dev/xvda",
            "Name": "amzn2-ami-hvm-2.0.20181114-x86_64-gp2",
            "OwnerId": "137112412989",
            "Public": true,
            "EnaSupport": true,
            "BlockDeviceMappings": [
                {
                    "DeviceName": "/dev/xvda",
                    "Ebs": {
                        "SnapshotId": "snap-0d789818b1297bb21",
                        "Encrypted": false,
                        "VolumeSize": 8,
                        "DeleteOnTermination": true,
                        "VolumeType": "gp2"
                    }
                }
            ]
        }
    ]
}
$ ec2=$(aws ec2 run-instances \
    --image-id ami-0a2de1c3b415889d2 \
    --key-name test-laravel \
    --security-group-ids ${security_group_id} \
    --subnet-id ${subnet_id} \
    --associate-public-ip-address \
    --instance-type t2.micro)
$ ec2_instance_id=$(echo $ec2 | jq -r ".Instances[].InstanceId")
$ aws ec2 create-tags \
    --resources ${ec2_instance_id} \
    --tags Key=Name,Value=test-laravel

EC2インスタンスにElastic IPを割り当てます。

$ elastic_ip=$(aws ec2 allocate-address)
$ ec2_ip=$(echo $elastic_ip | jq -r ".PublicIp")
$ elastic_ip_id=$(echo $elastic_ip | jq -r ".AllocationId")
$ aws ec2 associate-address \
    --allocation-id ${elastic_ip_id} \
    --instance ${ec2_instance_id}

これでEC2インスタンスSSHできるようになりました 🎉

$ ssh -i test-laravel.pem ec2-user@${ec2_ip}

続いてEC2インスタンスにnginx+Laravel環境を作ります。

まずは必要なnginxとphp-fpmをインストールします。

$ sudo amazon-linux-extras install nginx1.12 -y
$ sudo amazon-linux-extras install php7.2 -y

php-fpmのuserとgroupをnginxに変更します。

$ sudo yum install patch -y
$ cat www.conf.patch
--- www.conf
+++ www.conf
@@ -21,9 +21,9 @@
 ; Note: The user is mandatory. If the group is not set, the default user's group
 ;       will be used.
 ; RPM: apache user chosen to provide access to the same directories as httpd
-user = apache
+user = nginx
 ; RPM: Keep a group allowed to write in log dir.
-group = apache
+group = nginx

 ; The address on which to accept FastCGI requests.
 ; Valid syntaxes are:
$ sudo patch -u /etc/php-fpm.d/www.conf < www.conf.patch

ComposerとLaraveiに必要なPHPパッケージをインストールします。

$ curl -sS https://getcomposer.org/installer | sudo php
$ sudo cp composer.phar /usr/local/bin/composer
$ sudo ln -s /usr/local/bin/composer /usr/bin/composer

$ sudo yum install -y php-mbstring php-xml php-bcmath

Laraveiプロジェクトを作成します。

$ cd /usr/share/nginx/html/
$ sudo composer create-project --prefer-dist "laravel/laravel=5.7.*" test-laravel
$ sudo chown -R nginx ./test-laravel*
$ cd ~/

nginxのルートディレクトリをLaraveiプロジェクトに変更します。

$ cat nginx.conf.patch
--- nginx.conf
+++ nginx.conf
@@ -39,7 +39,7 @@
         listen       80 default_server;
         listen       [::]:80 default_server;
         server_name  _;
-        root         /usr/share/nginx/html;
+        root         /usr/share/nginx/html/test-laravel/public;

         # Load configuration files for the default server block.
         include /etc/nginx/default.d/*.conf;
$ sudo patch -u /etc/nginx/nginx.conf < nginx.conf.patch

nginxとphp-fpmを起動するとブラウザ(http://${ec2_ip})からアクセスできるようになります🎉

$ sudo systemctl start nginx.service
$ sudo systemctl start php-fpm.service

LaravelプロジェクトをCodeCommit管理にする

EC2で作成したLaravelプロジェクトをアーカイブします。

$ cd /usr/share/nginx/html
$ sudo tar cvfz test-laravel.tar.gz test-laravel
$ cd -
$ exit

Laravelプロジェクトのアーカイブを取得・展開してGitリポジトリを作成します。

$ scp -i test-laravel.pem -P 22 ec2-user@${ec2_ip}:/usr/share/nginx/html/test-laravel.tar.gz ./
$ tar xvfz test-laravel.tar.gz
$ cd test-laravel
$ git init
$ git add -A
$ git commit -m "Initial commit"

CodeCommitプロジェクトを作成しLaravelプロジェクトをpushします。

$ codecommit=$(aws codecommit create-repository --repository-name test-laravel)
$ codecommit_arn=$(echo $codecommit | jq -r ".repositoryMetadata.Arn")
$ git remote add origin ssh://git-codecommit.ap-northeast-1.amazonaws.com/v1/repos/test-laravel
$ git push origin master
$ cd ..

EC2インスタンスのLaravelプロジェクトのディレクトリ構成を自動デプロイ向けに変更する

下記のようにreleasesへタイムスタンプをディレクトリ名としてLaravelプロジェクトをデプロイします。そしてreleaseのシンボリック先を変更して必要な処理を行います。最後にcurrentが有効なプロジェクトへのシンボリックリンクになるように切り替えを行う方式にします。

/user/share/nginx/html/test-laravel
.
├── .env
├── current -> /user/share/nginx/html/laravel/releases/1533870527 ← ルートディレクトリ
├── release -> /user/share/nginx/html/laravel/releases/1533870527 ← デプロイ作業中のみできる
├── releases
│   └── 1533870527 ← タイムスタンプ
│       ├── .env -> /user/share/nginx/html/laravel/.env
│       ├── strage -> /user/share/nginx/html/laravel/shared/strage
│       └── その他のtest-laravelコード
└── shared
    └──strage

まず、nginxとphp-fpmを停止します。

$ ssh -i test-laravel.pem ec2-user@${ec2_ip}

$ sudo systemctl stop nginx.service
$ sudo systemctl stop php-fpm.service

ディレクトリ構成を変更します。

$ cd /usr/share/nginx/html/

$ timestamp=$(date +%s)

$ sudo mv test-laravel ${timestamp}
$ sudo mkdir -p test-laravel
$ cd test-laravel

$ sudo mkdir -p ./shared
$ sudo mkdir -p ./releases
$ sudo mv ../${timestamp} ./releases/

$ deploy_path="/usr/share/nginx/html/test-laravel"

$ sudo ln -nfs "${deploy_path}/releases/${timestamp}" ./release
$ sudo mv ./release/storage ./shared/
$ sudo mv ./release/.env ./

$ cd ./release
$ sudo ln -nfs ${deploy_path}/.env ./.env
$ sudo ln -nfs ${deploy_path}/shared/storage ./storage

$ cd ../

$ sudo ln -nfs "${deploy_path}/releases/${timestamp}" ./current
$ sudo unlink ./release

$ cd ../

$ sudo chown -R nginx ./test-laravel*

$ cd ~/

nginxのルートディレクトリを新しいディレクトリ構成に変更します。

$ cat nginx.conf.2.patch
--- nginx.conf
+++ nginx.conf
@@ -39,7 +39,7 @@
         listen       80 default_server;
         listen       [::]:80 default_server;
         server_name  _;
-        root         /usr/share/nginx/html/test-laravel/public;
+        root         /usr/share/nginx/html/test-laravel/current/public;

         # Load configuration files for the default server block.
         include /etc/nginx/default.d/*.conf;
$ sudo patch -u /etc/nginx/nginx.conf < nginx.conf.2.patch

nginxとphp-fpmを起動すると新しいディレクトリ構成でブラウザ(http://${ec2_ip})からアクセスできるようになります🎉

$ sudo systemctl start nginx.service
$ sudo systemctl start php-fpm.service

$ exit

CodeDeploy環境を整える

CodeDeployエージェントのためのIAMロールを作成してEC2インスタンスに割り当てます。

$ cat codedeploy-ec2-trust.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "ec2.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}
$ aws iam create-role \
    --role-name test-laravel-ec2 \
    --assume-role-policy-document file://codedeploy-ec2-trust.json
$ cat codedeploy-ec2-permissions.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:Get*",
                "s3:List*"
            ],
            "Effect": "Allow",
            "Resource": "*"
        }
    ]
}
$ policy_ec2=$( \
    aws iam create-policy \
        --policy-name test-laravel-ec2 \
        --policy-document file://codedeploy-ec2-permissions.json)
$ policy_ec2_arn=$(echo $policy_ec2 | jq -r ".Policy.Arn")
$ aws iam attach-role-policy \
    --policy-arn ${policy_ec2_arn} \
    --role-name test-laravel-ec2
$ aws iam create-instance-profile \
    --instance-profile-name test-laravel
$ aws iam add-role-to-instance-profile \
    --role-name test-laravel-ec2 \
    --instance-profile-name test-laravel
$ aws ec2 associate-iam-instance-profile \
    --instance-id ${ec2_instance_id} \
    --iam-instance-profile Name=test-laravel

EC2インスタンスにCodeDeployエージェントをインストールします。

$ ssh -i test-laravel.pem ec2-user@${ec2_ip}

$ sudo yum update
$ sudo yum install ruby -y 
$ sudo yum install wget -y
$ wget https://aws-codedeploy-ap-northeast-1.s3.amazonaws.com/latest/install
$ chmod +x ./install
$ sudo ./install auto

$ exit

CodeDeployのアプリケーションとデプロイグループを作成します。

$ cat deploy_trust_policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "",
      "Effect": "Allow",
      "Principal": {
        "Service": "codedeploy.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
$ role_deploy=$(aws iam create-role --role-name test-laravel-deploy \
                      --assume-role-policy-document file://deploy_trust_policy.json)
$ role_deploy_arn=$(echo $role_deploy | jq -r ".Role.Arn")
$ aws iam attach-role-policy \
    --policy-arn arn:aws:iam::aws:policy/service-role/AWSCodeDeployRole \
    --role-name test-laravel-deploy
$ aws deploy create-application --application-name test-laravel
$ aws deploy create-deployment-group \
    --application-name test-laravel \
    --deployment-group-name test-laravel \
    --deployment-config-name CodeDeployDefault.AllAtOnce \
    --ec2-tag-filters Key=Name,Value=test-laravel,Type=KEY_AND_VALUE \
    --service-role-arn ${role_deploy_arn}

デプロイスクリプトaws_deploy.shとCodeDeploy向けの構築定義ファイルappspec.ymlをLaravelプロジェクトに追加します。

$ cd test-laravel
$ mkdir bin
$ cat bin/aws_deploy.sh

aws_deploy.shの内容は下記です。

#!/bin/bash

deploy_path="/usr/share/nginx/html/test-laravel"

# デプロイ先
cd ${deploy_path} || exit 99

timestamp=$(date +%s)

# 必要なディレクトリを作成
mkdir -p ./shared
mkdir -p "./releases/${timestamp}"

# 作業ディレクトリにデプロイ
ln -nfs "${deploy_path}/releases/${timestamp}" ./release
cp -arf /tmp/test_laravel/* ./release/

# デプロイ対象外ファイルを用意
if [ ! -d ./shared/storage ]; then
  cp -arf ./release/storage ./shared/
  chmod 777 -R ./shared/storage
fi

chown -R nginx ./*

cd ./release || exit 99

# デプロイ対象外ファイルをシンボリックリンク化
rm -f .env
ln -nfs ${deploy_path}/.env ./.env
chown -h nginx ./.env
rm -rf ./storage
ln -nfs ${deploy_path}/shared/storage ./storage
chown -h nginx ./storage

# 各種コマンド実施
php artisan storage:link
php artisan view:clear
php artisan cache:clear
composer dump-autoload --optimize

cd ../ || exit 99

# デプロイ実施
ln -nfs "${deploy_path}/releases/${timestamp}" ./current
unlink ./release

# php-fpm/nginx再起動して適用
systemctl restart php-fpm.service
systemctl restart nginx.service

続きます。

$ chmod 755 bin/aws_deploy.sh
$ cat appspec.yml

appspec.ymlの内容は下記です。

version: 0.0
os: linux
files:
  - source: ./
    destination: /tmp/test_laravel
permissions:
  - object: /tmp/test_laravel
    pattern: "**"
    owner: nginx
hooks:
  AfterInstall:
    - location: bin/aws_deploy.sh
      timeout: 300

続きます。

$ git add -A
$ git commit -m "Add support for CodeDeploy"
$ git push origin master

$ cd ..

CodeBuild環境を整える

まずCodeBuildプロジェクトをEC2インスタンスと同じ環境にするためECRにAmazon Linux 2 AMI 2.0.20181114 x86_64 HVM gp2のdockerイメージを登録して使えるようにします。

$ ecr_login=$(aws ecr get-login --no-include-email)
$ echo $ecr_login > ecr_login.sh
$ chmod 755 ecr_login.sh
$ bash ./ecr_login.sh

$ ecr_repo=$(aws ecr create-repository --repository-name test-laravel)
$ ecr_repo_url=$(echo $ecr_repo | jq -r ".repository.repositoryUri")

$ docker pull amazonlinux:2.0.20181114
$ docker tag amazonlinux:2.0.20181114 ${ecr_repo_url}/test-laravel:latest
$ docker push ${ecr_repo_url}:latest

CodeBuildからアクセスできるようにECRを設定します。

$ cat ecr_policy.json
{
    "Version": "2008-10-17",
    "Statement": [
        {
            "Sid": "CodeBuildAccess",
            "Effect": "Allow",
            "Principal": {
                "Service": "codebuild.amazonaws.com"
            },
            "Action": [
                "ecr:GetDownloadUrlForLayer",
                "ecr:BatchGetImage",
                "ecr:BatchCheckLayerAvailability"
            ]
        }
    ]
}
$ aws ecr set-repository-policy --repository-name test-laravel --policy-text file://ecr_policy.json

CodeBuildのIAMロールを作成します。

$ cat codebuild-trust-policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "codebuild.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}
$ role_build=$(aws iam create-role --role-name test-larabel-build \
                      --assume-role-policy-document file://codebuild-trust-policy.json)
$ role_build_arn=$(echo $role_build | jq -r ".Role.Arn")
$ aws iam attach-role-policy --policy-arn arn:aws:iam::aws:policy/AdministratorAccess \
                             --role-name test-larabel-build

CodeBuildを作成します。

$ cat Source.json
{
  "type": "CODECOMMIT",
  "location": "https://git-codecommit.ap-northeast-1.amazonaws.com/v1/repos/test-laravel"
}
$ cat Artifacts.json
{
  "type": "NO_ARTIFACTS"
}
$ cat Environment_Template.json
{
  "type": "LINUX_CONTAINER",
  "image": ":ecr_repo_url::latest",
  "computeType": "BUILD_GENERAL1_SMALL"
}
$ cat Environment_Template.json | sed s/:ecr_repo_url:/$(echo ${ecr_repo_url} | sed "s/\//\\\\\//g")/ > Environment.json
$ aws codebuild create-project --name test-laravel \
                               --source file://Source.json \
                               --artifacts file://Artifacts.json \
                               --environment file://Environment.json \
                               --service-role ${role_build_arn}

CodeBuild向けの構築定義ファイルbuildspec.ymlをLaravelプロジェクトに追加します。

$ cd test-laravel
$ cat buildspec.yml

buildspec.ymlの内容は下記です。

version: 0.2

phases:
  install:
    commands:
      - |
          amazon-linux-extras install php7.2 -y
          yum install -y php-mbstring php-xml php-bcmath

          curl -sS https://getcomposer.org/installer | php
          cp composer.phar /usr/local/bin/composer
          ln -s /usr/local/bin/composer /usr/bin/composer

          yum install -y git
          yum install -y zip unzip
  build:
    commands:
      - rm -rf .git .gitignore README.md
      - composer install --verbose --prefer-dist --no-progress --no-interaction --no-dev --optimize-autoloader

artifacts:
  files:
    - '**/*'

続きます。

$ git add -A
$ git commit -m "Add support for CodeBuild"
$ git push origin master

$ cd ..

CodePipeline環境を整える

CodePipeline向けのIAMロールを作成します。

$ cat codepipeline-trust-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Service": "codepipeline.amazonaws.com"
      },
      "Action": "sts:AssumeRole"
    }
  ]
}
$ role_pipeline=$(aws iam create-role --role-name test-larabel-pipeline \
                      --assume-role-policy-document file://codepipeline-trust-policy.json)
$ role_pipeline_arn=$(echo $role_pipeline | jq -r ".Role.Arn")
$ aws iam attach-role-policy --policy-arn arn:aws:iam::aws:policy/AdministratorAccess \
                             --role-name test-larabel-pipeline

アーティファクトに設定するS3バケットを作成します。*1

$ aws s3 mb s3://test-laravel-pipeline --region ap-northeast-1

CodePipelineを作成します。

$ cat pipeline_template.json
{
    "version": 1,
    "name": "test-laravel",
    "roleArn": ":role_pipeline_arn:",
    "artifactStore": {
        "type": "S3",
        "location": "test-laravel-pipeline"
    },
    "stages": [
        {
            "name": "Source",
            "actions": [
                {
                    "inputArtifacts": [],
                    "configuration": {
                        "PollForSourceChanges": "true",
                        "BranchName": "master",
                        "RepositoryName": "test-laravel"
                    },
                    "name": "Source",
                    "outputArtifacts": [
                        {
                            "name": "SourceArtifact"
                        }
                    ],
                    "actionTypeId": {
                        "version": "1",
                        "category": "Source",
                        "owner": "AWS",
                        "provider": "CodeCommit"
                    },
                    "runOrder": 1
                }
            ]
        },
        {
            "name": "Build",
            "actions": [
                {
                    "inputArtifacts": [
                        {
                            "name": "SourceArtifact"
                        }
                    ],
                    "configuration": {
                        "ProjectName": "test-laravel"
                    },
                    "name": "Build",
                    "outputArtifacts": [
                        {
                            "name": "BuildArtifact"
                        }
                    ],
                    "actionTypeId": {
                        "version": "1",
                        "category": "Build",
                        "owner": "AWS",
                        "provider": "CodeBuild"
                    },
                    "runOrder": 1
                }
            ]
        },
        {
            "name": "Deploy",
            "actions": [
                {
                    "inputArtifacts": [
                        {
                            "name": "BuildArtifact"
                        }
                    ],
                    "configuration": {
                        "DeploymentGroupName": "test-laravel",
                        "ApplicationName": "test-laravel"
                    },
                    "name": "Deploy",
                    "outputArtifacts": [],
                    "actionTypeId": {
                        "version": "1",
                        "category": "Deploy",
                        "owner": "AWS",
                        "provider": "CodeDeploy"
                    },
                    "runOrder": 1
                }
            ]
        }
    ]
}
$ cat pipeline_template.json | sed s/:role_pipeline_arn:/$(echo ${role_pipeline_arn} | sed "s/\//\\\\\//g")/ > pipeline.json
$ aws codepipeline create-pipeline --pipeline file://pipeline.json
$ pipeline=$(aws codepipeline get-pipeline --name test-laravel)
$ pipeline_arn=$(echo $pipeline | jq -r ".metadata.pipelineArn")

作成すると自動で実行が開始してmasterブランチがデプロイされます🎉 結果はコンソールで確認できます。

CodeBuildをキャッシュ対応する

このままでも動くのですが毎回composer installを実行するのは無駄です。composer.lockに変化がない場合は前回キャッシュしたvendorを使うようにします。

まずキャッシュを格納するs3バケットを作成します。*2

$ aws s3 mb s3://test-laravel-codebuild-cache --region ap-northeast-1

CodeBuildにキャッシュ設定を行います。

$ cat codebuild_cache.json
{
  "type": "S3",
  "location": "test-laravel-codebuild-cache"
}
$ aws codebuild  update-project --name test-laravel --cache file://codebuild_cache.json

Laravelプロジェクトのbuildspec.ymlを更新します。

$ cat buildspec.yml.patch

buildspec.yml.patchの内容は下記です。

--- buildspec.yml
+++ buildspec.yml
@@ -4,20 +4,36 @@
   install:
     commands:
       - |
-          amazon-linux-extras install php7.2 -y
-          yum install -y php-mbstring php-xml php-bcmath
+          yum install -y tar
+          if [ -e /tmp/composer.lock ] && [ -e /tmp/vendor.tar ]; then
+            diff /tmp/composer.lock ./composer.lock
+            if [ $? -eq 0 ]; then
+              tar xf /tmp/vendor.tar
+            fi
+          fi
+          if [ ! -e ./vendor ]; then
+            amazon-linux-extras install php7.2 -y
+            yum install -y php-mbstring php-xml php-bcmath

-          curl -sS https://getcomposer.org/installer | php
-          cp composer.phar /usr/local/bin/composer
-          ln -s /usr/local/bin/composer /usr/bin/composer
+            curl -sS https://getcomposer.org/installer | php
+            cp composer.phar /usr/local/bin/composer
+            ln -s /usr/local/bin/composer /usr/bin/composer

-          yum install -y git
-          yum install -y zip unzip
+            yum install -y git
+            yum install -y zip unzip
+          fi
   build:
     commands:
       - rm -rf .git .gitignore README.md
-      - composer install --verbose --prefer-dist --no-progress --no-interaction --no-dev --optimize-autoloader
-
+      - if [ ! -e ./vendor ]; then composer install --verbose --prefer-dist --no-progress --no-interaction --no-dev --optimize-autoloader; fi
+  post_build:
+    commands:
+      - cp composer.lock /tmp
+      - tar cf /tmp/vendor.tar vendor
 artifacts:
   files:
     - '**/*'
+cache:
+  paths:
+    - /tmp/composer.lock
+    - /tmp/vendor.tar

続きます。

$ patch -u ./test-laravel/buildspec.yml < buildspec.yml.patch

$ cd test-laravel
$ git add -A
$ git commit -m "Use CodeBuild cache"
$ git push origin master

$ cd ..

CodeBuildを実行するとS3バケットにvendorのキャッシュが作成されます。 結果はコンソールから確認できます。

$ aws codepipeline start-pipeline-execution --name test-laravel

再度CodeBuildを実行するとvendorのキャッシュが使われるので短い時間で完了します🎉 結果はコンソールから確認できます。

$ aws codepipeline start-pipeline-execution --name test-laravel

CloudWatch EventsでCodePipelineが自動実行されるよう設定する

CodeCommitを監視するCloudWatch Events向けのIAMロールを作成します。

$ cat cwe-trust-policy.json
{
    "Version": "2012-10-17",
    "Statement": [
      {
        "Effect": "Allow",
        "Principal": {
          "Service": "events.amazonaws.com"
        },
        "Action": "sts:AssumeRole"
      }
    ]
}
$ role_cwe=$(aws iam create-role --role-name test-laravel-cwe \
                      --assume-role-policy-document file://cwe-trust-policy.json)
$ role_cwe_arn=$(echo $role_cwe | jq -r ".Role.Arn")
$ aws iam attach-role-policy --policy-arn arn:aws:iam::aws:policy/AdministratorAccess \
                             --role-name test-laravel-cwe

CloudWatch Eventsを作成します。

$ cat event_pattern_template.json
{
  "source": [
    "aws.codecommit"
  ],
  "detail-type": [
    "CodeCommit Repository State Change"
  ],
  "resources": [
    ":codecommit_arn:"
  ],
  "detail": {
    "event": [
      "referenceCreated",
      "referenceUpdated"
    ],
    "referenceType": [
      "branch"
    ],
    "referenceName": [
      "master"
    ]
  }
}
$ cat event_pattern_template.json | sed s/:codecommit_arn:/$(echo ${codecommit_arn} | sed "s/\//\\\\\//g")/ > event_pattern.json
$ aws events put-rule \
  --name "TestLaravel" \
  --event-pattern file://event_pattern.json \
  --role-arn ${role_cwe_arn}
$ aws events put-targets \
  --rule TestLaravel \
  --targets "Id"="1","Arn"="${pipeline_arn}","RoleArn"="${role_cwe_arn}"

これでmasterへのpushでCodePipelineが自動実行されるようになりました🎉🎉🎉 結果はコンソールから確認できます。

$ cat welcome.blade.php.patch
--- welcome.blade.php
+++ welcome.blade.php
@@ -81,7 +81,7 @@

             <div class="content">
                 <div class="title m-b-md">
-                    Laravel
+                    Hello World!
                 </div>

                 <div class="links">
$ patch -u ./test-laravel/resources/views/welcome.blade.php < welcome.blade.php.patch

$ cd test-laravel
$ git add -A
$ git commit -m "Test master push"
$ git push origin master

$ cd ..

しばらくしてブラウザ(http://${ec2_ip})からアクセスするとトップページの文字列がLaravelからHello World!に変わります。

まとめ

今回はプライベートな時間を利用してLaravelプロジェクトをCodeCommitのmasterブランチにpushしたらEC2に自動デプロイされる環境を構築する手順をまとめました。Code4兄弟でのデプロイ環境構築の経験はコンソールで少しありましたがCLIはほぼ無かったので勉強になりました。

構築した環境には改善した方が良い点もたくさんありますので今回の手順を土台にして色々と挑戦してみるのも良いかもしれません。

  • デプロイの前に自動テストが実行されるようにする
  • releasesの古いデプロイフォルダを自動的に削除する
  • IAMロールの設定を必要最小限にする
  • ロードバランサーを導入したりもうちょっと実用に耐えられるVPC構成にする
  • AWS CLIでもまだまだ保守大変そうなのでTerraformなどに置き換える
  • ECSやEKSなどもう少しモダンな構成にする
  • etc

*1:バケット名がすでに使われている場合は適当に変更します

*2:バケット名がすでに使われている場合は適当に変更します

Angular6でマテリアルデザイン/ReduxアーキテクチャなSPAフロントエンドを作ってみた

前回前々回の引き続きでサンプルの空っぽのフロントエンドを動くように実装してみました。

何を作ったのか

こんな見た目のフロントエンドを作りました。*1

f:id:nihma:20181027171654g:plain

今回の記事のコードとgitのタグです。

使い方

dockerを使ってローカル環境で実行する方法とAWSで動かす方法があります。

docker環境で動かす

まずリポジトリをcloneします。

$ git clone -b v0.0.1 https://github.com/nihemak/aws-sls-spa-sample-web.git sample-spa-web
$ cd sample-spa-web

そしてdocker環境のビルドを行います。

$ npm run docker-build

単体で動かす方法とaws-sls-spa-sample-apiを使う方法があります。

単体で動かす方法

まず、docker環境でng serveを実行します。

$ npm run docker-up
$ npm run docker-serve

するとブラウザからアクセスできるようになります。

http://localhost:4200/

aws-sls-spa-sample-apiを使う方法

まず、aws-sls-spa-sample-apiをDocker環境で起動します。

前回の「docker環境で動かす」の通りです。ここのgit cloneするタグを今回のタグに変えます。

そして、docker-compose.ymlに定義した環境変数API_BASE_URLaws-sls-spa-sample-apiのURL('http://localhost:3000')に変更します。

$ git diff dockers/docker-compose.yml
   angular:
     build: ./angular
     tty: true
     ports:
       - 4200:4200
     environment:
-      API_BASE_URL: 'mock-server'
+      API_BASE_URL: 'http://localhost:3000'
     volumes:
       - ../:/src/

あとは単体で動かす方法と同じです。

$ npm run docker-up
$ npm run docker-serve

これでブラウザからアクセスできます。

http://localhost:4200/

AWS環境で動かす

前々回の「使い方」の通りです。ここのgit cloneするタグを今回のタグに変えます。タグを変える箇所は前回の「AWS環境で動かす」の通りです。

実装

具体的な実装はこちらです。

github.com

マテリアルデザイン

マテリアルデザインGoogleが提唱しているデザインの考え方です。

material.io

saruwakakun.com

今回は@angular/materialを使いました。ライブラリで用意してあるコンポーネントを組み合わせるだけでマテリアルデザインになります。

Angular Material

そして、@angular/flex-layoutを使ってFlexboxでレイアウトしました。

CSS Flexible Box Layout Module Level 1 (日本語訳)

github.com

対応したコード差分は下記あたりです。

Change to Material Design with @angular/material and @angular/flex-layout by nihemak · Pull Request #11 · nihemak/aws-sls-spa-sample-web · GitHub

Reduxアーキテクチャ

今回は@ngrxを使いました。*2

qiita.com

www.slideshare.net

対応したコード差分は下記あたりです。

Change to Redux Architecture with NgRx by nihemak · Pull Request #7 · nihemak/aws-sls-spa-sample-web · GitHub

Http Client / モックAPI

今回はフロントエンド単独でも動作できるようにしたかったのでAPIモックのためにin-memory-web-apiを使いました。

Angularチュートリアル - HTTP

neos21.hatenablog.com

neos21.hatenablog.com

そして、通信先のAPIをモックにするかHTTPにするかの切り替えは環境変数API_BASE_URL'mock-server'を指定するか否かをAppModuleで判断して行うようにしました。

ただ、Angularにはng buildng serveのタイミングで環境変数から値を取得する標準的な仕組みはありませんでした。そのため、pre_build.shというbashを用意してnpm scriptng buildng serveの直前に環境変数からsrc/app/environments/environment.tsを動的に生成するようにしました。*3

対応したコード差分は下記あたりです。

Use HttpClient with angular-in-memory-web-api by nihemak · Pull Request #8 · nihemak/aws-sls-spa-sample-web · GitHub

Move api url to environment variable by nihemak · Pull Request #9 · nihemak/aws-sls-spa-sample-web · GitHub

まとめ

今回はAngular6でお手軽にマテリアルデザイン/ReduxアーキテクチャなTodoリストのフロントエンドのサンプルを実装してみました。Angularはフルスタックなライブラリですので用意された機能の組み合わせだけでもそれらしいフロントエンドができました。

心残りはたくさんあります。

  • バリデーションがない
  • エラー処理がない
  • テストがない
  • etcetc

気が向いたら対応するかもしれません。

*1:レイアウトの調整などはほとんどしてません

*2:現時点で対応してなかったのでAngular7にはできませんでした

*3:あまり良い方法ではないと思いますが

Create a Clean Architecture REST API with Serverless Framework and TypeScript

This article is a translation of the original article.

I implemented an empty API of sample prepared in the previous article*1 so that it works.

Specification of API

I created todo's REST API.

  • POST /todos : Register todo
  • GET /todos : List todos
  • GET /todos/:id : Get todo
  • PUT /todos/:id : Update todo
  • DELETE /todos/:id : Delete todo

Architecture

Final goal (but I can not get it...)

I tried to make it to Clean Architecture.

Clean Architecture is an architecture that attempts to improve maintainability by separating abstract implementations (business logic etc.) from concrete implementation (framework, DB, etc.) as follows.

  • Divide the system into four layers (Entities, Use Cases, Interface Adapters, Frameworks and Drivers)
  • Make dependency between layers one-way from concrete layer to abstract layer (Frameworks and Drivers -> Interface Adapters -> Use Cases -> Entities)

If the dependencies between the layers do not match the order of the calls, it is necessary to adjust the direction of dependency between the layers by making use of the dependency inversion principle.

The figure is easier to understand than the explanation. (The figure quoted The Clean Architecture.)

f:id:nihma:20180917114525j:plain

This article

In the architecture design of this article, I did not follow all the principles of Clean Architecture, but we adopted only the purpose / way of thinking that improves the maintainability of the abstract layer by one way from concrete layer to abstract layer. Because if follow all principles that implementation will increase too much and disadvantage will increase.

Specifically, I broke the rules as follows and obeyed the principle only dependency between layers one-way from Interface Adapters, Frameworks and Drivers to Entities and Use Cases.

  • Interface Adapters is a layer that maps concrete layers (Frameworks and Drivers) and abstract layers (Entities and Use Cases). And this layer may depend on all layers.

f:id:nihma:20180917114858p:plain

The direction of the arrow between Interface Adapters andUse Cases is all inward.

You can probably reuse business logic even if you want to change Framework, DB etc.

Implementation

I uploaded the code to GitHub.

The directory structure is as follows.

$ tree -ad -I '.git'
.
├── .circleci ... config of CircleCI
├── app       ... application codes
│   ├── adapters    ... layer of Interface Adapters
│   │   ├── commands
│   │   ├── databases
│   │   └── http
│   │       ├── requests
│   │       └── responses
│   ├── commands    ... layer of Frameworks and Drivers(command)
│   ├── databases   ... layer of Frameworks and Drivers(DynamoDB)
│   ├── entities    ... layer of Entities
│   ├── http        ... layer of Frameworks and Drivers(Serverless Framework)
│   │   ├── controllers
│   │   ├── middlewares
│   │   ├── models
│   │   ├── utils
│   │   └── views
│   ├── providers   ... setting of DI (inversify)
│   └── usecases    ... layer of Use Cases
│       ├── implementations
│       ├── inputs
│       ├── outputs
│       └── stores
├── dockers    ... config of docker
│   ├── dynamodb
│   └── serverless
└── test       ... test codes
    ├── units
    │   ├── http
    │   │   ├── controllers
    │   │   └── middlewares
    │   └── usecases
    └── utils

33 directories

In implementation of this article, I used middy for http's middleware engine. The middleware directory is app/http/middlewares/.

aws-serverless-express and serverless-http were also candidates. However, I tried using middy which is specialized in middleware so as not to use two routing frameworks of Serverless Framework and Express. middy is still alpha version so it may not work with production code, but I felt it was fairly easy to use.

Also, by checking with tslint, we made it comply with tslint-config-standard. And I tried to write a unit test with mocha and keep it clean.

How to use

There are two ways to run it in the local environment using docker or to run it with AWS.

Using docker

Clone the Repository:

$ git clone -b v0.0.1 https://github.com/nihemak/aws-sls-spa-sample-api.git sample-spa-api
$ cd sample-spa-api

Build docker environment and Reset DynamoDB Local tables:

$ npm run docker-build
$ npm run docker-up-dev
$ npm run docker-reset-tables
$ npm run docker-down

Invoke serverless-offline in docker environment:

$ npm run docker-up
$ npm run docker-offline

Try the APIs:

# POST /todos
$ curl -X POST http://localhost:3000/v1/todos -H "Content-Type: application/json" --data '{ "text": "foo" }'
# GET /todos
$ curl http://localhost:3000/v1/todos
# GET /todos/:id
$ curl http://localhost:3000/v1/todos/:id
# PUT /todos/:id
$ curl -X PUT http://localhost:3000/v1/todos/:id -H "Content-Type: application/json" --data '{ "text": "bar", "checked": true }'
# DELETE /todos/:id
$ curl -X DELETE http://localhost:3000/v1/todos/:id

Access to DynamoDB Local admin web:

http://localhost:8000/

With AWS

It is as the How to use of the previous article.

But the implementation of this article is tag v.0.0.1. Therefore, you need to change only the tag part.

Specifically, the place to Clone the Repository and Push to the AWS CodeCommit Repository will change to the following.

# spa infra repository...
$ git clone -b v0.0.1 https://github.com/nihemak/aws-sls-spa-sample-terraform.git sample-spa-infra
$ cd sample-spa-infra
$ git checkout -b master
$ git push ssh://git-codecommit.ap-northeast-1.amazonaws.com/v1/repos/foobar-sample-spa-infra
$ cd ..
# spa api repository...
$ git clone -b v0.0.1 https://github.com/nihemak/aws-sls-spa-sample-api.git sample-spa-api
$ cd sample-spa-api
$ git checkout -b master
$ git push ssh://git-codecommit.ap-northeast-1.amazonaws.com/v1/repos/foobar-sample-spa-api
$ cd ..
# spa web repository...
$ git clone -b v0.0.0 https://github.com/nihemak/aws-sls-spa-sample-web.git sample-spa-web
$ cd sample-spa-web
$ git checkout -b master
$ git push ssh://git-codecommit.ap-northeast-1.amazonaws.com/v1/repos/foobar-sample-spa-web
$ cd ..

The repositories of aws-sls-spa-sample-terraform.git and aws-sls-spa-sample-api.git will be v0.0.1.

Summary

In this article, I made a sample REST API to manage Todo with Serverless Framework and TypeScript.

  • I was able to understand the concept and implementation cost of Clean Architecture.
  • I was able to learn TypeScript coding.

*1:This is a Japanese article. I used Terraform to code AWS 'serverless service infrastructure construction.

Serverless FrameworkとTypeScriptでClean ArchitectureライクなREST APIを作ってみる

前回のTerraformでAWSサーバーレスなサービスのインフラ構築をコード化する - Inside Closure - にへろぐではサンプルとして空っぽのAPIを用意しました。せっかくなのでこのサンプルを動くように実装してみました。

何を作ったのか

特にネタがなかったのでTodoを登録・一覧・取得・更新・削除できるだけのREST APIを作成しました。

  • POST /todos : Register todo
  • GET /todos : List todos
  • GET /todos/:id : Get todo
  • PUT /todos/:id : Update todo
  • DELETE /todos/:id : Delete todo

アーキテクチャ

目指したところ

Clean Architecture(日本語訳)の考え方を取り入れたアーキテクチャにしようと思いました。*1

Clean Architectureとはざっくり下記のようにして抽象的な実装(ビジネスロジックなど)を具体的な実装(フレームワークやDBなど)から切り離して保守性を高めようとするアーキテクチャです。

  • システムを4つのレイヤ(Entities, Use Cases, Interface Adapters, Frameworks and Drivers)にわける
  • レイヤ間の依存を具体的なレイヤから抽象的なレイヤへの一方通行にする(Frameworks and DriversInterface AdaptersUse CasesEntities)

レイヤ間の依存と処理/呼び出しの順序が一致しない場合は依存性逆転の原則を駆使するなどしてレイヤ間の依存の方向を整える必要があります。

図の方がわかりやすいです。(原文より)

f:id:nihma:20180916144257j:plain

実際のところ

今回はClean Architectureの原則に全て従うのではなく具体レイヤから抽象レイヤへ一方通行にして抽象レイヤの保守性を上げるという目的/考え方だけを取り入れた構成にしました。原則に全て従うのはお遊びだしめんどくさいため小規模な開発では実装が肥大してやりすぎ感がありデメリットがメリットを上回ると判断したためです。

具体的にはルールを下記のように崩してInterface Adapters, Frameworks and DriversからEntities and Use Casesが一方通行になるところだけ原則に従いました。

  • Interface Adaptersの責務を具体レイヤ(Frameworks and Drivers)と抽象レイヤ(Entities and Use Cases)のマッピングとする。そして、全てのレイヤに依存して良いことにする。

f:id:nihma:20180916151219p:plain

Interface AdaptersUse Casesの間の矢印の向きは全て内向きになってます。*2

これだけでもひとまずServerless FrameworkやLambda、DynamoDBなどをやめたくなってもビジネスロジックは(ほぼ)再利用できるような気がします。たぶん。

実装

コードをGitHubにアップしました。

大まかなディレクトリ構成です。

$ tree -ad -I '.git'
.
├── .circleci ... CircleCI用
├── app       .. アプリケーションコード
│   ├── adapters    ... Interface Adaptersレイヤ
│   │   ├── commands
│   │   ├── databases
│   │   └── http
│   │       ├── requests
│   │       └── responses
│   ├── commands    ... Frameworks and Driversレイヤ(コマンド)
│   ├── databases   ... Frameworks and Driversレイヤ(DynamoDB)
│   ├── entities    ... Entitiesレイヤ
│   ├── http        ... Frameworks and Driversレイヤ(Serverless Framework)
│   │   ├── controllers
│   │   ├── middlewares
│   │   ├── models
│   │   ├── utils
│   │   └── views
│   ├── providers   ... DI関連
│   └── usecases    ... Use Casesレイヤ
│       ├── implementations
│       ├── inputs
│       ├── outputs
│       └── stores
├── dockers    ... docker環境設定
│   ├── dynamodb
│   └── serverless
└── test       ... テストコード
    ├── units
    │   ├── http
    │   │   ├── controllers
    │   │   └── middlewares
    │   └── usecases
    └── utils

33 directories

今回、middleware engineにはmiddyを使ってhttp周りの処理をミドルウェアに切り出しapp/http/middlewaresにまとめました。

aws-serverless-expressserverless-httpでもよかったのですがServerless FrameworkExpressフレームワーク2つを使うことになるのが何となく嫌だったのでミドルウェアに特化しているmiddyを使ってみました。まだバージョンがalphaですので業務では使えないかもですがそこそこ使いやすい気がしました。

あと一応、tslintでtslint-config-standardに準拠するようにとmochaでユニットテストを(できるだけ)書いてクリーンな状態を保つように心がけました。*3

使い方

dockerを使ってローカル環境で実行する方法とAWSで動かす方法があります。

docker環境で動かす

まずリポジトリをcloneします。

$ git clone -b v0.0.1 https://github.com/nihemak/aws-sls-spa-sample-api.git sample-spa-api
$ cd sample-spa-api

そしてdocker環境のビルドとDynamoDB Localの初期化を行います。

$ npm run docker-build
$ npm run docker-up-dev
$ npm run docker-reset-tables
$ npm run docker-down

最後にdocker環境でserverless-offlineを実行します。

$ npm run docker-up
$ npm run docker-offline

するとAPIが使えます。

# POST /todos
$ curl -X POST http://localhost:3000/v1/todos -H "Content-Type: application/json" --data '{ "text": "foo" }'
# GET /todos
$ curl http://localhost:3000/v1/todos
# GET /todos/:id
$ curl http://localhost:3000/v1/todos/:id
# PUT /todos/:id
$ curl -X PUT http://localhost:3000/v1/todos/:id -H "Content-Type: application/json" --data '{ "text": "bar", "checked": true }'
# DELETE /todos/:id
$ curl -X DELETE http://localhost:3000/v1/todos/:id

DynamoDB Localの管理画面へは下記からアクセスできます。

http://localhost:8000/

AWS環境で動かす

TerraformでAWSサーバーレスなサービスのインフラ構築をコード化する - Inside Closure - にへろぐ使い方の通りです。

ただ今回のコードにはタグv0.0.1を打ってあるのでそこだけは変える必要があります。具体的にはCodeCommitのリポジトリにGitHubのリポジトリをpushする箇所が下記に変わります。

# spa infra repository...
$ git clone -b v0.0.1 https://github.com/nihemak/aws-sls-spa-sample-terraform.git sample-spa-infra
$ cd sample-spa-infra
$ git checkout -b master
$ git push ssh://git-codecommit.ap-northeast-1.amazonaws.com/v1/repos/foobar-sample-spa-infra
$ cd ..
# spa api repository...
$ git clone -b v0.0.1 https://github.com/nihemak/aws-sls-spa-sample-api.git sample-spa-api
$ cd sample-spa-api
$ git checkout -b master
$ git push ssh://git-codecommit.ap-northeast-1.amazonaws.com/v1/repos/foobar-sample-spa-api
$ cd ..
# spa web repository...
$ git clone -b v0.0.0 https://github.com/nihemak/aws-sls-spa-sample-web.git sample-spa-web
$ cd sample-spa-web
$ git checkout -b master
$ git push ssh://git-codecommit.ap-northeast-1.amazonaws.com/v1/repos/foobar-sample-spa-web
$ cd ..

aws-sls-spa-sample-terraform.gitaws-sls-spa-sample-api.gitのclone元がv0.0.1になります。

まとめ

今回はServerless FrameworkとTypeScriptでTodoを管理するREST APIのサンプルを作ってみました。

  • Clean Architectureの考え方の理解とやりすぎると実装コストが膨らみそうなことの実感を得ることができました
  • 普通にTypeScriptを書くのはほぼ初めてだったので苦戦するところもありましたが文法が少し分かるくらいにはなれた気がするのでよかったです

*1:ちょうど書籍のClean Architecture 達人に学ぶソフトウェアの構造と設計を読んでいたというだけの理由で

*2:他のレイヤ間も依存性逆転の原則を使って矢印の向きを内向きにすることはできると思いますがレイヤ間マッピングだらけになると思います

*3:心がけただけです

TerraformでAWSサーバーレスなサービスのインフラ構築をコード化する

最近、TerraformでAWSサーバーレスなサービスのインフラ構築をコード化する機会があったので理解を深めるためにプライベートでも作ってみました。

AWSサーバーレスなサービスの全体像

今回、Terraformで構築をコード化する対象の環境です。SPA(Single Page Application)な構成としました。

f:id:nihma:20180814150726p:plain

大きくWebの部分とAPIの部分、その他の部分に分かれています。

Webの部分:

  • ユーザからアクセスを受ける CloudFront
  • SPAのHTMLを配置する S3 bucket

APIの部分:

  • SPAのHTMLからアクセスを受ける CloudFront
  • APIの機能を提供する Lambda Function
  • CloudFront と Lambda Function を橋渡しする API Gateway

その他の部分:

  • 認証のための Cognito
  • セキュリティ対策のための WAF
  • データ永続化のための Dynamo DB

インフラ構築の流れ

セットアップ用のCodeBuild

まず起点となるセットアップ用のCodeBuildです。

f:id:nihma:20180814162506p:plain

最初にTerraformを使って下記を構築します。

  • サービスのStaging環境を構築するCodeBuild
  • サービスのProduction環境を構築するCodeBuild
  • 上記のCodeBuildを順次実行するCodePipeline

そして、最後に構築したCodePipelineを実行してサービスのStaging環境とProduction環境を構築します。

Staging環境の構築後には承認アクションが必要なようにしました。Staging環境の確認で問題が見つかった場合は承認を却下して以降の構築を停止することもできます。

サービスの環境を構築するCodeBuild

次はサービスのStaging環境を構築するCodeBuildおよびProduction環境を構築するCodeBuildです。こちらもTerraformを使います。

f:id:nihma:20180814162517p:plain

まず、他のリソースに依存しない、WebやAPIでは無い部分の下記を構築します。

次にWebの下記を構築します。ここで得られたCloudFront(Web)のURLは後続で構築するAPIのCORSに設定します。

  • S3 bucket(Web)
  • CloudFront(Web)

そして、API部分の構築・デプロイを行うCodeBuildを構築・実行します。これで下記が構築・デプロイされます。下記の構築・デプロイには Serverless Framework を使います。ここで得られたAPI Gatewayドメイン名は後続で構築するCloudFront(API)の紐付け先に設定します。

次にAPIのCloudFrontを構築します。ここで得られたCloudFront(API)のURLは後続で構築するWebのHTMLにAPIの呼び出し先URLとして設定します。

  • CloudFront(API)

最後にWeb部分のデプロイを行うCodeBuildを構築および実行します。これで下記がデプロイされます。ここは Angular を使います。

  • HTML(Web)

Production環境の場合はこのタイミングで下記を構築します。これらもサービスのCodePipelineと同様にStaging環境の構築後には承認アクションがあります。

  • APIのStaging環境を構築・デプロイするCodeBuildとProduction環境を構築・デプロイするCodeBuildを順次実行するCodePipeline
  • WebのStaging環境にデプロイするCodeBuildとProduction環境にデプロイするCodeBuildを順次実行するCodePipeline

Staging環境の構築とProduction環境の構築を結ぶCodePipeline

サービス全体およびAPIの部分、Webの部分はそれぞれにCodePipelineが構築されます。

f:id:nihma:20180814173736p:plain

それぞれCodeCommitのmasterブランチに変化があると実行されます。

APIの部分とWebの部分のCodePipelineがあることでAPIの部分だけ、Webの部分だけでのデプロイが行えます。サービス全体のCodePipelineはAPIの部分とWebの部分も構築・デプロイを行います。

実装内容

コードをGitHubにアップしました。

aws-sls-spa-sample-apiaws-sls-spa-sample-webaws-sls-spa-sample-terraform でインフラ構築するための必要な最小限の実装しかありません。

使っているツールのバージョンは下記です。

  • Terraform: 0.11.7
  • Serverless Framework: 1.30.0
  • Angular: 6.0.8

インフラ部分:aws-sls-spa-sample-terraform

大まかなディレクトリ構成です。

.
├── bin ... シェルなど
├── buildspec_setup.yml      ... セットアップ用のCodeBuild向け
├── buildspec_staging.yml    ... サービスのStaging構築CodeBuild向け
├── buildspec_production.yml ... サービスのProduction構築CodeBuildCodeBuild向け
├── environments ... 構築フローに沿ったTerraformの定義
│   │── setup   ... セットアップ用
│   └── service ... サービス用
│       ├── base          ... その他の部分
│       │   ├── pre
│       │   └── after_api
│       ├── api           ... API部分
│       │   ├── staging
│       │   └── production
│       │── web           ... Web部分
│       │   ├── staging
│       │   └── production
│       └── pipeline      ... API部分/Web部分のCodePipeline
└── modules      ... 各リソースのTerraformの定義
    ├── cloudfront
    ├── cloudwatch_event
    ├── codebuild
    ├── codepipeline
    ├── cognito
    ├── dynamodb
    ├── iam
    ├── s3
    └── waf

CodeBuildが buildspec_xxxx.yml を呼び出して構築をしていきます。

それぞれのTerraformの定義は リファレンス を参照しつつ基本的に consoleで作成したリソースのterraform import を実施した結果から作りました。

例えばaws_cognito_user_poolならこんな感じです。

$ cat terraform.tfvars
access_key = "XXXXXXXXX"
secret_key = "XXXXXXXXX"
region     = "ap-northeast-1"

# 空の定義を用意する
$ cat main.tf
variable "access_key" {}
variable "secret_key" {}
variable "region"     {}

provider "aws" {
  access_key = "${var.access_key}"
  secret_key = "${var.secret_key}"
  region     = "${var.region}"
}

resource "aws_cognito_user_pool" "hoge" {
}

$ terraform init
# 空で定義したリソースをimportする
$ terraform import aws_cognito_user_pool.hoge ap-northeast-1_XXXXXX
# tfstateに定義がimportされるので参照して定義を作っていく...
$ cat terraform.tfstate

API部分:aws-sls-spa-sample-api

ほぼ Serverless Framework の aws-nodejs-typescript をテンプレートにして作成した素のままの状態です。

$ serverless create --template aws-nodejs-typescript

これに aws-sls-spa-sample-terraform で必要な buildspec.yml の追加と serverless.yml の修正を行いました。

Web部分:aws-sls-spa-sample-web

こちらもほぼ Angular で ng new した素のままの状態です。

$ ng new aws-sls-spa-sample-web --style=scss --routing

これに aws-sls-spa-sample-terraform で必要な buildspec.yml の追加と angular.json の修正を行いました。

使い方

セットアップ用のCodeBuildを手動で構築・実行してサービスのインフラ構築を行うまでの手順です。リソース名は例のため実際に実施するときは変える必要があるので注意が必要です。

1. GitHubリポジトリをCodeCommitに持ってくる

CodeCommitにリポジトリを作成します。

# spa infra repository...
$ aws codecommit create-repository --repository-name foobar-sample-spa-infra
# spa api repository...
$ aws codecommit create-repository --repository-name foobar-sample-spa-api
# spa web repository...
$ aws codecommit create-repository --repository-name foobar-sample-spa-web

作成したCodeCommitのリポジトリGitHubリポジトリをpushする。

# spa infra repository...
$ git clone -b v0.0.0 https://github.com/nihemak/aws-sls-spa-sample-terraform.git sample-spa-infra
$ cd sample-spa-infra
$ git checkout -b master
$ git push ssh://git-codecommit.ap-northeast-1.amazonaws.com/v1/repos/foobar-sample-spa-infra
$ cd ..
# spa api repository...
$ git clone -b v0.0.0 https://github.com/nihemak/aws-sls-spa-sample-api.git sample-spa-api
$ cd sample-spa-api
$ git checkout -b master
$ git push ssh://git-codecommit.ap-northeast-1.amazonaws.com/v1/repos/foobar-sample-spa-api
$ cd ..
# spa web repository...
$ git clone -b v0.0.0 https://github.com/nihemak/aws-sls-spa-sample-web.git sample-spa-web
$ cd sample-spa-web
$ git checkout -b master
$ git push ssh://git-codecommit.ap-northeast-1.amazonaws.com/v1/repos/foobar-sample-spa-web
$ cd ..

2. セットアップ用のCodeBuildを動かすために必要なリソースを作る

CodePipelineの承認アクションで使うSNSのトピックを作成する。

$ aws sns create-topic --name foobar-sample-spa-approval-topic
$ aws sns subscribe --topic-arn arn:aws:sns:ap-northeast-1:<account-id>:foobar-sample-spa-approval-topic \
                    --protocol email \
                    --notification-endpoint <your email>
# and confirm...
$ aws sns confirm-subscription --topic-arn arn:aws:sns:ap-northeast-1:<account-id>:foobar-sample-spa-approval-topic \
                               --token <token value>

Terraformの状態管理ファイルを保存するS3のバケットを作成する。S3のバケット名は全世界でユニークにする必要があるので注意が必要です。

$ aws s3 mb s3://foobar-sample-spa-terraform-state --region ap-northeast-1

CodeBuildを動かすために必要なIAMロールを作成する。 TF_VAR_service_name に指定した文字列が作成されるリソースのプレフィックスになるので他と被らない、長すぎない文字列を使うように注意が必要です。

$ cat Trust-Policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "codebuild.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}
$ aws iam create-role --role-name foobar-sample-spa-setup-codebuild \
                      --assume-role-policy-document file://Trust-Policy.json
$ aws iam attach-role-policy --policy-arn arn:aws:iam::aws:policy/AdministratorAccess \
                             --role-name foobar-sample-spa-setup-codebuild

セットアップ用のCodeBuildを作成する。

$ cat Source.json
{
  "type": "CODECOMMIT",
  "location": "https://git-codecommit.ap-northeast-1.amazonaws.com/v1/repos/foobar-sample-spa-infra",
  "buildspec": "buildspec_setup.yml"
}
$ cat Artifacts.json
{
  "type": "NO_ARTIFACTS"
}
$ cat Environment.json
{
  "type": "LINUX_CONTAINER",
  "image": "aws/codebuild/ubuntu-base:14.04",
  "computeType": "BUILD_GENERAL1_SMALL",
  "environmentVariables": [
    {
      "name": "TF_VAR_service_name",
      "value": "foobar-sample-spa",
      "type": "PLAINTEXT"
    },
    {
      "name": "TF_VAR_approval_sns_topic_arn",
      "value": "arn:aws:sns:ap-northeast-1:<account-id>:foobar-sample-spa-approval-topic",
      "type": "PLAINTEXT"
    },
    {
      "name": "TF_VAR_s3_bucket_terraform_state_id",
      "value": "foobar-sample-spa-terraform-state",
      "type": "PLAINTEXT"
    },
    {
      "name": "TF_VAR_codecommit_infra_repository",
      "value": "foobar-sample-spa-infra",
      "type": "PLAINTEXT"
    },
    {
      "name": "TF_VAR_codecommit_api_repository",
      "value": "foobar-sample-spa-api",
      "type": "PLAINTEXT"
    },
    {
      "name": "TF_VAR_codecommit_web_repository",
      "value": "foobar-sample-spa-web",
      "type": "PLAINTEXT"
    }
  ]
}
$ aws codebuild create-project --name foobar-sample-spa-setup \
                               --source file://Source.json \
                               --artifacts file://Artifacts.json \
                               --environment file://Environment.json \
                               --service-role arn:aws:iam::<account-id>:role/foobar-sample-spa-setup-codebuild

3. セットアップ用のCodeBuildを実行する

セットアップ用のCodeBuildを実行すると環境が順次、作成されていきます。リソースはそれぞれお金がかかるので注意が必要です。

$ aws codebuild start-build --project-name foobar-sample-spa-setup

WebのエンドポイントはCloudFrontのconsoleから探せます。

f:id:nihma:20180818173759p:plain

httpsでのみアクセスできます。反映まで少しかかるのでアクセスできるようになるまで少し待つ必要があります。

https://xxxxxx.cloudfront.net

中身がないのでAngularのデフォルトページです。

f:id:nihma:20180816220608p:plain

もしここでアクセスした時に307リダイレクトが起きたらだいぶ待つ必要があるみたいです...(何度か遭遇しました https://stackoverflow.com/questions/38706424/aws-cloudfront-returns-http-307-when-origin-is-s3-bucket

まとめ

今回はTerraformでAWSサーバーレスなサービスのインフラ構築をするサンプルを作ってみました。心残りは下記です。

  • インフラ構成をもうちょい見直したい
  • IAM権限が適当なのでちゃんと絞りたい
  • API/Webを動くようにしたい
  • Terraformは保守大変・インフラ構成変更時の挙動が怪しいなどあるので他の選択肢も試したい
  • AWS以外も試したい

Chainerでサクッとニューラル英日翻訳を試してみた

これはChainer Advent Calendar 2016の6日目です。

qiita.com

Chainerによる実践深層学習がとても読みやすくて面白かったので8章の翻訳モデルを参考に英日翻訳するプログラムを組んで遊んでみました。

今回の環境

今回は下記のMacBook Airで実施しました。

  • OS: macOS Sierra バージョン 10.12.1
  • プロセッサ: 1.7GHz Intel Core i7*
  • メモリ: 8GB

使ったライブラリなどのバージョンは下記の通りです。

  • Python: 3.5.1
  • Chainer: 1.18.0
  • Numpy: 1.10.4

あとはMacに形態素解析エンジンMeCabをインストール - Qiitaを参考にmecab(0.996)とIPA辞書(2.7.0-20070801)をインストールしました。

そしてPython3で形態素解析エンジンMeCabを使えるようにする(2016年3月版) - Qiitaを参考にmecab-python3もインストールしました。

訓練データ

英日翻訳を訓練するための英文と和文の対訳データはTatoeba例文と日本語索引を加工して作りました。

具体的には、まず最初に例文と日本語索引をダウンロードしました。

$ sh en2ja_data_download.sh

sentences.csvjpn_indices.csvが取得できます。

en2ja_data_download.shの中身は下記です。

そして、ダウンロードしたデータを加工して英文と和文の対訳データを作成しました。

$ python en2ja_setup.py

実行すると対訳の対応する英文と和文が同じ行になるようにそれぞれ英文ファイルen.txt、和文ファイルja.txtが作られます。 英文は全て小文字に変換、和文はmecab分かち書きに変換しています。 なお、今回は諸事情*1により訓練に使用した対訳データはランダムにサンプリングした2500対のみになります。

en2ja_setup.pyの中身は下記です。

翻訳モデル

Encoder-Decoder翻訳モデルにAttentionを導入したモデルを使ってみました。

f:id:nihma:20161206001154p:plain

モデルの説明はChainerによる実践深層学習以外では下記あたりが参考になると思います。

モデルを実装したTranslator.pyは下記です。

訓練の実施

今回は対訳データの学習を100回実施しました。 1回の学習ごとにTranslatorモデルのシリアライズデータをen2ja-[学習回数].modeというファイルに吐き出します。

$ python en2ja_learn.py
model new start.
embed_size: 100, source_size: 4503, target_size: 4350
model new finished. elapsed_time: 0.1[sec]
1 / 100 epoch start.
1 / 2500 line finished.
   ...
2500 / 2500 line finished.
1 / 100 epoch finished. elapsed_time: 329.8[sec] remaining_time: 32648.4[sec]
2 / 100 epoch start.
1 / 2500 line finished.
   ...
2500 / 2500 line finished.
100 / 100 epoch finished. elapsed_time: 303.1[sec] remaining_time: 0.0[sec]

この学習には8.5時間かかりました。 ちなみに今回の訓練では英語の語彙数は4503語、日本語の語彙数は4350語だったようです。*2

en2ja_learn.pyの中身は下記です。

翻訳のテスト

訓練の際に吐き出した各学習回数ごとのモデルを使って翻訳結果を出してみました。 en2ja_test.pyen-test.txtというファイルに書かれている英文を和文に翻訳します。

下記ではたまたま今回の訓練データに入っていたit's one thing to make plans, but quite another to carry them out.を試しに翻訳してみた結果です。訓練データの対応する和文は計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。です。

$ echo "it's one thing to make plans, but quite another to carry them out." > en-test.txt 
$ cat en-test.txt
it's one thing to make plans, but quite another to carry them out.
$ python en2ja_test.py
0: 私 は その 仕事 に は 、 私 の 人 に は 、 私 は 、 私 の 人 に は 、 私 は 、 私 の 人 に は 、 私
1: 私 は 彼 の 髪 が 好き です 。 <eos>
2: 私 達 は 、 その 仕事 に は ない 。 <eos>
3: 私 達 は 、 その 仕事 に 向い て いる 。 <eos>
4: 私 達 は 学校 で い つ そう し ない の は 、 私 達 の 言う こと が できる ん が あり ます 。 <eos>
5: テレビ は 、 乳製品 を 学ぶ ため に は 1 0 冊 の が ある 。 <eos>
6: 私 達 は 学校 を し て いる 。 <eos>
7: はい 、 眠く あり ませ ん が 、 実際 は 誰 に なっ た 。 <eos>
8: 計画 を 立てる 事 と し て いる の です が 。 <eos>
9: 計画 を 立てる 事 と 、 すぐ に は 正確 な 白鳥 を する こと が 好き だ 。 <eos>
10: 計画 を 立てる 事 と 、 すぐ に 平静 を 取り戻し た 。 <eos>
11: 計画 は 、 前例 の だ 。 <eos>
12: 計画 を 立てる 事 と し て い た 。 <eos>
13: 計画 を 立てる 事 と 、 それ は あり ます 。 <eos>
14: 計画 を 立てる 事 と 、 それ を する こと が 理解 でき ない 。 <eos>
15: 計画 を 立てる 事 と 、 すぐ に 腹 が 何 も 言わ なかっ た 。 <eos>
16: 計画 を 立てる 事 と し て も いい です 。 <eos>
17: 計画 を 立てる 事 と 、 私 は どこ で 聞け たい 。 <eos>
18: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
19: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は すぐ 帰っ て い たら 、 それ は 全く あり ませ ん 。 <eos>
20: 計画 を 立てる 事 と 、 それ は ない と 思っ て い た 。 <eos>
21: 計画 を 立てる 事 と し て も いい よ 。 <eos>
22: 計画 を 立てる 事 に は いつも 不平 たらたら だ 。 <eos>
23: 計画 を 立てる 事 と 、 それ を 実行 する こと が できる 。 <eos>
24: 計画 に は もう を し て いる 。 <eos>
25: 計画 を 立てる 事 と 、 それ は ない ため に なっ た 。 <eos>
26: 計画 を 立てる 事 と の ところ 上手く 行っ た 。 <eos>
27: 私 に は もう 車 に 忠実 で い た 。 <eos>
28: 計画 を 立てる 事 と 、 それ は 全く あり に 行き まし た 。 <eos>
29: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 の ある ラジオ だ よ 。 <eos>
30: 計画 を 立てる 事 と 、 それ を 実行 余裕 が ない 。 <eos>
31: 計画 を 立てる 事 と 、 それ は 全く 別 だ から 。 <eos>
32: 計画 を 立てる 事 と 、 今 は もう 勉強 し て いる ところ です 。 <eos>
33: 今 によって なく も びっくり し た ので 、 彼ら の 自由 に は 体 を 動かす こと が ない 。 <eos>
34: 計画 を 立てる 事 まで の ところ 上手く 行っ た 。 <eos>
35: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
36: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
37: 私 に たち の 部屋 を 先週 何 でも いっ て いる 人 が い ます 。 <eos>
38: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
39: 計画 を 立てる 事 と 、 それ は 収入 に どこ に も かしこ に も あり ませ ん 。 <eos>
40: 計画 を 立てる 事 と 、 彼 の 勇気 が 悪い 。 <eos>
41: 計画 を 立てる 事 に 会っ た 。 <eos>
42: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
43: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
44: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
45: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
46: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
47: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
48: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
49: 計画 を 立てる 事 と 、 それ が とりわけ 年少 の 者 たち に 影響 を つい た もの だ 。 <eos>
50: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
51: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
52: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
53: 計画 を 立てる 事 と 、 それ を 実行 する 事 と 関係 に なる 。 <eos>
54: 君 に は もう 宿題 を し て いる 。 <eos>
55: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
56: 計画 を 立てる 事 と 、 彼 の もの は 計画 に 不可能 と 失敗 し た 。 <eos>
57: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
58: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
59: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
60: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
61: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 の ある 経済 いくら て ない ? <eos>
62: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
63: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
64: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
65: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
66: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
67: 計画 を 立てる 事 と 、 それ を 実行 する 事 が 好き です 。 <eos>
68: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
69: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
70: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
71: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
72: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
73: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
74: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
75: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
76: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
77: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
78: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
79: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
80: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
81: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は すぐ 慣れる だろ う 。 <eos>
82: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
83: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
84: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
85: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
86: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
87: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
88: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
89: 計画 を 立てる 事 と 、 それ を 実行 する 事 と 子供 事 が わたし です 。 <eos>
90: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
91: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
92: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
93: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
94: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
95: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
96: 計画 を 立てる 事 と 、 つい 達 の 電話 す べき 事故 だ 。 <eos>
97: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
98: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>
99: 計画 を 立てる 事 と 、 それ を 実行 する 事 と は 全く 別 だ 。 <eos>

19回目の学習データで初めて正解になるもすぐに不正解になり36回目くらいから徐々に正解が出るようになってくるっぽい動きが見えて面白いです。

en2ja_test.pyの中身は下記です。

さいごに

今後は今回あまり時間がなくできなかった下記のようなことをしてみたいです。

  • 理論的な理解を深める
  • 今回できなかった大きめのデータでの訓練を実施してみる
  • パラメータを調整し精度の比較を行ってみる

*1:対訳データすべて(149704対)を使って訓練を実施しようとしたところ丸100日間ほどかかりそうだったので今回は対象を絞りました。

*2:今回しませんでしたが対訳データすべて(149704対)の場合の語彙数は40341語、日本語の語彙数は31789語だったようです。