Docker + Nginx + Let’s EncryptでHTTPS対応のプロキシサーバーを構築する

Docker上にNginxコンテナをプロキシサーバーとして構築し、Let’s EncryptでHTTPS対応しました。構築にあたって かなり苦戦した ので、そのノウハウを記事としてまとめました。

「Nginx」とは

Apacheなどの従来のWebサーバーは、クライアントの数が多くなるとサーバーがパンクする 「C10K問題(クライアント1万台問題)」 を抱えていました。「Nginx」はこの問題を解決するために誕生した、静的コンテンツを高速配信するWebサーバーです。2017年10月現在、そのシェアは Apacheとほぼ同等 となっています。

wpid-wss-share13.png

Webサーバー シェア
Micosoft IIS 49.44%
Apache 18.78%
Nginx 18.40%

「Let’s Encrypt」とは

「Let’s Encrypt」は すべてのWebサーバへの接続を暗号化する ことを目指し、SSL/TLSサーバ証明書を 無料 で発行する認証局(CA)です。シスコ、Akamai、電子フロンティア財団、モジラ財団などの大手企業・団体がスポンサーとして支援しています。


本稿が目指すシステム構成

本稿ではAmazon EC2、Dockerコンテナを使用して以下のようなシステムを構築することを目標とします。

DockerでNgixのプロキシサーバーを構築する.png

前提条件

  • 独自ドメインを取得していること(本稿で使用するドメインはexample.comとします)
  • IPv4パブリックIP(Elastic IP)がEC2インスタンスに設定されていること
  • EC2インスタンスにDocker、docker-composeがインストールされていること

事前に準備すること

DockerでHTTPS対応のプロキシサーバーを構築するにあたり、事前に以下の設定をしておく必要があります。

  • EC2のインバウンドルールで443ポートを開放する
  • DNSのAレコードを設定する
  • プロキシ用のネットワークを構築する

EC2のインバウンドルールで443ポートを開放する

インバウンドルールを以下のように設定し、443ポートを外部へ公開します。

タイプ プロトコル ポート範囲 ソース
HTTPS TCP 443 0.0.0.0/0
HTTPS TCP 443 ::/0

DNSのAレコードを設定する

DNSの設定方法は利用しているドメイン取得サービスによって異なります。例えばバリュードメインの場合、DNSの設定方法は「DNS情報・URL転送の設定 | VALUE-DOMAIN ユーザーガイド」に記載されています。

DNSのAレコードを以下のように設定します。xx.xx.xx.xxにはEC2インスタンスに割り当てられているIPv4パブリックIPを設定します。

a @ xx.xx.xx.xx
a www xx.xx.xx.xx

上記設定は以下を意味します。

  • example.com(サブドメイン無し)をIPアドレスxx.xx.xx.xxにポイントする
  • www.example.com をIPアドレスxx.xx.xx.xxにポイントする

プロキシ用のネットワークを構築する

プロキシサーバーとWebサーバー間のネットワークは外部との通信を行う必要がありません。そこで
プロキシサーバーとWebサーバー間の 内部ネットワーク を構築するため、EC2のインスタンスにログインし、以下のコマンドを入力します。

$ docker network create --internal sample_proxy_nw

上記コマンドは以下を意味します。

  • --internal: ネットワーク外との通信が行えないネットワークを作成します。
  • sample_proxy_nw: 任意のネットワーク名です。

以下のコマンドを入力し、ネットワークの設定情報がコンソールに出力されていることを確認しましょう。

$ docker network inspect sample_proxy_nw

Dockerコンテナの定義ファイルを作成する

事前準備が完了したら、Dockerコンテナの定義ファイルを作成しましょう。本稿におけるディレクトリ構成は以下のとおりです。

/path/to/dir/

.
├── docker-compose.yml // プロキシサーバーとWebサーバーのコンテナを定義するファイル
└── proxy
    ├── default.conf // プロキシサーバー上にあるNginxのデフォルト定義ファイル
    ├── Dockerfile // プロキシサーバーのイメージを構築するためのファイル
    └── entrypoint.sh // プロキシサーバーにSSL証明書を取得するためのファイル

以下では、各ファイルの内容を解説します。

./docker-compose.yml

docker-compose.ymlでは、以下のコンテナを定義しています。

  • proxy: プロキシサーバー(Nginxベース)
  • web1: Webサーバー(httpdベース)
  • web2: Webサーバー(httpdベース)
version: '3'
services:
  proxy:
    build: ./proxy
    tty: true
    image: sample_proxy
    container_name: sample_proxy
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
    ports:
      - "443:443"
    volumes:
      - '/srv/letsencrypt:/etc/letsencrypt'
    networks:
      - default
      - sample_proxy_nw
    depends_on:
      - "web1"
      - "web2"
    command: ["wait-for-it.sh", "sample_web1:80", "--", "./wait-for-it.sh", "sample_web2:80"]
  web1:
    image: httpd
    container_name: sample_web1
    tty: true
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
    networks:
      - sample_proxy_nw
  web2:
    image: httpd
    container_name: sample_web2
    tty: true
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
    networks:
      - sample_proxy_nw
networks:
  proxy_nw:
    external: true

上記コマンドは以下を意味します。

  • サービスproxyports: 外部からのHTTPSアクセスとproxyサーバーの内部ポートを疎通させるため、443:443を定義します。
  • サービスproxyvolumes: /srv/letsencrypt:/etc/letsencryptを定義します。/etc/letsencryptLet’s Encryptで取得した証明書が生成されるディレクトリ です。
  • networks: 上述の説明で生成したsample_proxy_nwを各サービス(proxy, web1, web2)に定義します。
  • depends_on: コンテナの起動順序を制御するオプションです。 Nginxのproxy_passに設定されているWebサーバーが起動していない状態でプロキシサーバーが起動した場合にエラーとなる ため、web1, web2を設定します。

./proxy/default.conf

./proxy/default.confはNginxのデフォルト定義ファイル(/etc/nginx/conf.d/default.conf)を書き換えるためのファイルです。

server{

    server_name example.com www.example.com;

    proxy_redirect off;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    location / {
        proxy_pass    http://sample_web1/;
    }

    location /example/ {
        proxy_pass    http://sample_web2/;
    }

}

上記設定は以下を意味します。

  • server_name: ユーザーから要求されるHTTPリクエストのヘッダに含まれるHostフィールドとserver_nameが一致した場合、該当するサーバ設定を採用します。Nginxではキャッチオールサーバーとして_を定義することもできますが、 certbot-autoがサーバー情報を正しく取得することができない ため、上記のようにドメイン名を入力します。
  • location: ルートディレクトリ(example.com/)とサブディレクトリ(example.com/example/)にアクセスした際の振り分け先URIを設定します。proxy_passには、http://[コンテナ名]/を設定します。コンテナ名はdocker-compose.ymlのcontainer_nameで設定した名前となります。
    また、http://sample_web1/のように 末尾に/を入れる ことに注意しましょう。例えばlocation /example/において、プロキシパスの末尾に/が含まれていない(http://sample_web2)場合、振り分け先は http://sample_web2/example/となってしまいます。

./proxy/Dockerfile

FROM nginx
COPY default.conf /etc/nginx/conf.d/default.conf
RUN apt-get update && apt-get install -y \
        wget && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*
ADD https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh /usr/local/bin/wait-for-it.sh
RUN chmod +x /usr/local/bin/wait-for-it.sh
ADD https://dl.eff.org/certbot-auto /usr/local/bin/certbot-auto
RUN chmod a+x /usr/local/bin/certbot-auto
RUN certbot-auto --os-packages-only -n
COPY ./entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]

上記設定は以下を意味します。

  • ADD https://dl.eff.org/certbot-auto /usr/local/bin/certbot-auto: Let’s Encryptが発行するSSL/TLSサーバ証明書を自動で取得・更新するツール「 certbot-auto 」をダウンロードします。

./proxy/entrypoint.sh

#!/bin/bash
certbot-auto --nginx -d example.com -d www.example.com -m your-account@gmail.com --agree-tos -n
certbot-auto renew
/bin/bash

上記設定は以下を意味します。

  • --nginx: プロキシサーバーにNginxを使用する場合のオプションです。default.confの設定を自動的に書き換えます。(2017年12月現在、アルファ版のプラグイン)
  • -d example.com -d www.example.com: SSL/TLSサーバ証明書の取得を申請するドメイン名を指定します。
  • -m your-account@gmail.com: アカウントの登録や回復などに使用する電子メールアドレスを指定します。
  • --agree-tos: Let’s Encryptの利用規約に同意します。
  • -n: インタラクティブ設定をオフにします。
  • ./certbot-auto renew: 3ヶ月で失効する SSL/TLSサーバ証明書を自動で更新します。

以下のコマンドを入力してentrypoint.shに 実行権限を付与する ことを忘れないようにしましょう。

$ chmod +x entrypoint.sh

Dockerコンテナを起動する

それでは以下のコマンドを入力してDockerコンテナを起動しましょう。

docker-compose up -d

しばらく時間をおいてから、以下のコマンドを入力します。

docker-compose logs

以下のように出力されていれば成功です。

-------------------------------------------------------------------------------
Congratulations! You have successfully enabled https://example,com and
https://www.example.com

You should test your configuration at:
https://www.ssllabs.com/ssltest/analyze.html?d=example.com
https://www.ssllabs.com/ssltest/analyze.html?d=www.example.com
-------------------------------------------------------------------------------

HTTPSでアクセスする

ブラウザを起動し、実際に以下のURLにアクセスしてみましょう。

Chromeブラウザの場合はデベロッパーツール > Security > View certificateからSSL/TLSサーバ証明書を確認することができます。

「発行元: Let’s Encrypt Authority X3」となっているはずです。

続きを読む

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 などを自力で叩く実装ができれば、これが実現できるのかもしれません。

今回作ったコード

参考

続きを読む

ゼロからはじめるServerless Java Container

日頃AWSやその他クラウドサービスを使ってインテグレーションしていく中で、 https://github.com/awslabs を定期的にウォッチしているのですが、その中で Serverless Java Container が気になったので試してみました。

https://github.com/awslabs/aws-serverless-java-container

Serverless Java Container is 何?

簡単に言うと、API GatewayとLambdaを使ったサーバレスアプリケーションを Jersey, Spark, Spring Frameworkといったフレームワークを使って作るためのライブラリです。

このライブラリを利用することで、「つなぎ」となる必要最低限のコードを書いてあげさえすれば、あとはいつも通り、フレームワークの流儀に沿ってアプリケーションを実装していくだけで、Lambda上で動くハンドラができあがる、というシロモノです。
絶対不可欠なライブラリではありませんが、あると便利なので、一考の価値はあると思います。


で、このAdvent Calendarにエントリした時には気づいていなかったのですが、AWSの中の人がこのライブラリについて詳しく紹介しているスライド・動画があることに気づきました :neutral_face:
「AWS Dev Day Tokyo 2017」で登壇された時のものみたいですね。

というわけで、このライブラリについての詳細な解説については上記を参考にしてもらうとして、今回のエントリでは、以下のような違いを出しつつ、このライブラリを使ってアプリケーションを作ってみることにします。

  • SAMやCloudFormationなどを使わずに、ゼロから構築してみる
  • よくあるPetStoreアプリケーションではなく、Hello, worldアプリケーションを作る
  • ビルドにはMavenではなくGradleを使う

試してみる

アプリケーションの雛形を作る

Spring Initialzrから新規にGradleプロジェクトを作っていきます。
必要な入力項目は以下のとおり。

項目 入力値
Group com.example
Artifact demo
Dependencies DevTools

「Generate Project」を押すと、プロジェクトの雛形がZipファイルで作られるので、展開後のディレクトリをワークスペースとします。

依存関係にServerless Java Containerを追加

今回の主役となるライブラリを追加します。執筆時点での最新バージョンは0.8のようでした。

build.gradle
    compile('com.amazonaws.serverless:aws-serverless-java-container-spring:0.8')

READMEにしたがってConfigとLambdaHandlerを作成

Serverless Java ContainerリポジトリのREADMEの「Spring support」のセクションを参考にして、アプリケーションとLambdaの「つなぎ」となる部分のコードを作っていきます。

まずはコンフィグ。

com.example.demo.AppConfig.java
@Configuration
@ComponentScan("com.example.demo")
public class AppConfig {
}

続いてハンドラ。

com.example.demo.LambdaHandler.java
public class LambdaHandler implements RequestHandler<AwsProxyRequest, AwsProxyResponse> {

    private static class Singleton {

        static SpringLambdaContainerHandler<AwsProxyRequest, AwsProxyResponse> handler = instance();

        static SpringLambdaContainerHandler<AwsProxyRequest, AwsProxyResponse> instance() {
            try {
                return SpringLambdaContainerHandler.getAwsProxyHandler(AppConfig.class);
            } catch (final ContainerInitializationException e) {
                throw new RuntimeException("Cannot get Spring Lambda Handler", e);
            }
        }
    }

    @Override
    public AwsProxyResponse handleRequest(AwsProxyRequest awsProxyRequest, Context context) {
        return Singleton.handler.proxy(awsProxyRequest, context);
    }
}

LambdaHandler の方は、READMEの通りに実装するとコンパイルエラーになってしまうので、少し修正しました。
SpringLambdaContainerHandler.getAwsProxyHandler がチェック例外 ContainerInitializationException を投げるので、そのままフィールドとして初期化できないんですよね…。

コントローラを作る

準備が終わったので、アプリケーション本体を作っていきます。
とは言え、今回は簡単なHello, Worldアプリケーションなので、これだけです。

com.example.demo.controller.DemoController.java
@RestController
public class DemoController {

    @GetMapping("/hello")
    public String hello(@RequestParam(required = false) Optional<String> message) {
        return "Hello, " + message.orElse("world") + "!";
    }
}

普通のSpringアプリケーションのコードですね。

Lambda用のパッケージの作成

下記ドキュメントを参考に、Lambda用のパッケージを作ります。
http://docs.aws.amazon.com/ja_jp/lambda/latest/dg/create-deployment-pkg-zip-java.html

実は、普段Lambda関数を作る時はランタイムとしてNode.jsを使うことが多く、Javaランタイムを使うのは始めてでした。
build.gradle に以下を追加すればOKです。

build.gradle
task buildZip(type: Zip) {
    from compileJava
    from processResources
    into('lib') {
        from configurations.runtime
    }
}

build.dependsOn buildZip

Lambda関数を作ってパッケージをアップロード

Lambda関数を作って、パッケージをアップロードします。

ここで関数をテストする場合、イベントテンプレートとして「API Gateway AWS Proxy」を選択すればよいです。
選択して出てくるテンプレート中で、実装したアプリケーションに合わせて

  • "queryStringParameters""message": "好きな文字列"
  • "httpMethod""GET"
  • "path""/hello"

としてそれぞれ変更してください(下図)。

lambda.png

API Gatewayと連携させる

仕上げに、APIを作り、デプロイします。

今回は、ルートの直下にプロキシリソースを作ってしまいます(プロキシリソースの呼び出し先のLambda関数は、上記で作成したLambda関数を指定してください)

apigw2.png

テストの際は、クエリ文字列として message=好きな文字列(今回はServerless) を指定し、GETメソッドを呼び出すと、Springのコントローラが発火し、

Hello, Serverless!!

がレスポンスとして返ってくることが確認できます。

また、APIをデプロイした後は、curl コマンドなどでアクセスしても、きちんとレスポンスが取得できることが確認します。

$ curl "https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/hello?message=Serverless"

まとめ

繰り返しになってしまいますが、Serverless Java Containerライブラリ、簡単に言うと、API GatewayのLambdaプロキシ利用時に、普通のSpringアプリケーションとしてハンドラを実装できるようにするためのラッパー、という感じだと思います。
API Gatewayと組み合わせて力を発揮するライブラリですね。
最初は、Lambda上でSpringを使う時の足回りを面倒見てくれるライブラリかな?と思ったのですが、ちょっとイメージしていたものとはズレていました…。

現実的には、RDBに依存したアプリケーションの場合のコネクションの話1など、既存のWebアプリケーションがそのまま載せ替えられるか?というと検討ポイントはありそうですが、使い慣れたフレームワークをサーバレスアプリケーション化する場合には、こういったライブラリの活用もよいのかなーと思いました。

おまけ:起動時間など

LambdaのランタイムとしてJavaを使っている場合、起動時間も少し気になるところだと思うので、メモしておきます。

ちなみに、メモリの設定は512MBです。

正確なベンチマークは取得できていませんが、今回のアプリケーションで確認した範囲においては、10ミリ秒前後の処理時間で済むみたいです(コンテナが起動して、ApplicationContextが初期化済みになっている場合ですが)。

続きを読む

KubernetesでAWS ALBを自動作成する〜ついでにRoute53 Record Setも

kube-ingress-aws-controllerを使います。

kube-ingress-aws-controllerとは

Zalandoが公開している、Kubernetes用のIngress Controllerの一つです。

ZalandoはKubernetes界隈では著名な、ヨーロッパでファッションECをやっている企業です。
Kubernetesコミュニティへの様々な形で貢献していて、今回紹介するkube-aws-ingerss-controllerや先日紹介したexternal-dnsもその一つです。

何かできるのか

KubernetesユーザがAWSを全く触らずとも

  • ALBの自動作成
  • ALBに割り当てるTLS証明書(ACM管理)を自動選択

をしてくれます。

使い方

KubernetesのIngressリソースを普段通りつくります。

kubectl create -f myingress.yaml

すると、1~2分ほどでIngressリソースに書いたホスト名でインターネットからアクセスできるようになります。

open https://myingress.exapmle.com

Ingress Controllerとは

KubernetesのIngressはL7ロードバランサのスペックのようなもので、そのスペックから実際にL7ロードバランサをセットアップするのがIngress Controllerの役割です。

coreos/alb-ingress-controllerとの違い

coreos/alb-ingress-controller

  • Ingressリソース一つに対して、1 ALBをつくります

zalando-incubator/kube-ingress-aws-controller

  • ACM証明書のドメイン一つに対して、ALBを割り当てます
  • 同じドメイン名に対するルートを含むIngressリソースは、一つのALBにまとめられます
  • ALBのターゲットグループにEC2インスタンスを割り当てるところまでしかやってくれない!ので、実際にIngressとして利用するためには他のIngress Controllerを併用する必要があります

kube-ingress-aws-controllerのセットアップ手順

Security Groupの作成

kube-ingress-aws-controllerはALBに割り当てるSecurity Groupまでは自動作成してくれないので、AWSコンソール等で作成します。

kube-ingerss-aws-controllerのドキュメントにはCloudFormationを使った手順がかいてあります。

同等のSecurity Groupをawscliで作る場合は以下のようなコマンドを実行します。

CLUSTER_NAME=...
VPC_ID=vpc-...

aws ec2 create-security-group \
  --description ${CLUSTER_NAME}-kube-aws-ingress-controller-alb \
  --group-name ${CLUSTER_NAME}-kube-aws-ingress-controller-alb \
  --vpc-id $VPC_ID | tee sg.json

SG_ID=$(jq -r '.GroupId' sg.json)

aws ec2 create-tags --resources $SG_ID --tags \
  "Key=\"kubernetes.io/cluster/$CLUSTER_NAME\",Value=owned" \
  "Key=\"kubernetes:application\",Value=kube-ingress-aws-controller"

aws ec2 authorize-security-group-ingress \
  --group-id $SG_ID \
  --ip-permissions '[{"IpProtocol": "tcp", "FromPort": 80, "ToPort": 80, "IpRanges": [{"CidrIp": "0.0.0.0/0"}]}, {"IpProtocol": "tcp", "FromPort": 443, "ToPort": 443, "IpRanges": [{"CidrIp": "0.0.0.0/0"}]}]'

aws ec2 describe-security-groups --group-id $SG_ID

# あとで: 不要になったらクラスタやVPCの削除前に以下のように削除
aws ec2 delete-security-group --group-id $SG_ID

IAMポリシーの割当

kube-ingerss-aws-controllerのドキュメントにIAM Policy Statementの一覧がかいてありますが、要約すると

  • CloudFormationスタックのCRUD権限
  • ACM証明書、VPC、RouteTable、Subnet、Security Group、AutoScalingGroup、EC2 InstanceのGet/List/Describe権限
  • ALBのCRUD権限

が必要です。

kube-awsのcluster.yamlの場合は、以下のように書きます。

worker:
  nodePools:
  - iam:
      policy:
        statements:
        - effect: Allow
          actions:
          - "autoscaling:DescribeAutoScalingGroups"
          - "autoscaling:AttachLoadBalancers"
          - "autoscaling:DetachLoadBalancers"
          - "autoscaling:DetachLoadBalancerTargetGroup"
          - "autoscaling:AttachLoadBalancerTargetGroups"
          - "elasticloadbalancing:AddTags"
          - "elasticloadbalancing:DescribeLoadBalancers"
          - "elasticloadbalancing:CreateLoadBalancer"
          - "elasticloadbalancing:DeleteLoadBalancer"
          - "elasticloadbalancing:DescribeListeners"
          - "elasticloadbalancing:CreateListener"
          - "elasticloadbalancing:DeleteListener"
          - "elasticloadbalancing:DescribeTags"
          - "elasticloadbalancing:CreateTargetGroup"
          - "elasticloadbalancing:DeleteTargetGroup"
          - "elasticloadbalancing:DescribeTargetGroups"
          - "elasticloadbalancingv2:DescribeTargetGroups"
          - "elasticloadbalancingv2:DescribeLoadBalancers"
          - "elasticloadbalancingv2:CreateLoadBalancer"
          - "elasticloadbalancingv2:DeleteLoadBalancer"
          - "elasticloadbalancingv2:DescribeListeners"
          - "elasticloadbalancingv2:CreateListener"
          - "elasticloadbalancingv2:DeleteListener"
          - "elasticloadbalancingv2:DescribeTags"
          - "elasticloadbalancingv2:CreateTargetGroup"
          - "elasticloadbalancingv2:DeleteTargetGroup"
          - "ec2:DescribeInstances"
          - "ec2:DescribeSubnets"
          - "ec2:DescribeSecurityGroup"
          - "ec2:DescribeRouteTables"
          - "ec2:DescribeVpcs"
          - "acm:ListCertificates"
          - "acm:DescribeCertificate"
          - "iam:ListServerCertificates"
          - "iam:GetServerCertificate"
          - "cloudformation:Get*"
          - "cloudformation:Describe*"
          - "cloudformation:List*"
          - "cloudformation:Create*"
          - "cloudformation:Delete*"
          resources:
          - "*"

kube-aws-ingress-controllerをデプロイ

$ kubectl apply -f kube-aws-ingress-controller.yaml
kube-aws-ingress-controller.yaml
apiVersion: apps/v1beta1
kind: Deployment
metadata:
  name: kube-ingress-aws-controller
  namespace: kube-system
  labels:
    application: kube-ingress-aws-controller
    component: ingress
spec:
  replicas: 1
  selector:
    matchLabels:
      application: kube-ingress-aws-controller
      component: ingress
  template:
    metadata:
      labels:
        application: kube-ingress-aws-controller
        component: ingress
    spec:
      containers:
      - name: controller
        image: registry.opensource.zalan.do/teapot/kube-ingress-aws-controller:latest
        env:
        - name: AWS_REGION
          value: ap-northeast-1

併用するIngress Controllerをデプロイ

今回はskipperを使ってみます。

apiVersion: extensions/v1beta1
kind: DaemonSet
metadata:
  name: skipper-ingress
  namespace: kube-system
  labels:
    component: ingress
spec:
  selector:
    matchLabels:
      application: skipper-ingress
  updateStrategy:
    type: RollingUpdate
  template:
    metadata:
      name: skipper-ingress
      labels:
        component: ingress
        application: skipper-ingress
    spec:
      hostNetwork: true
      containers:
      - name: skipper-ingress
        image: registry.opensource.zalan.do/pathfinder/skipper:latest
        ports:
        - name: ingress-port
          containerPort: 9999
          hostPort: 9999
        args:
          - "skipper"
          - "-kubernetes"
          - "-kubernetes-in-cluster"
          - "-address=:9999"
          - "-proxy-preserve-host"
          - "-serve-host-metrics"
          - "-enable-ratelimits"
          - "-experimental-upgrade"
          - "-metrics-exp-decay-sample"
          - "-kubernetes-https-redirect=true"
        resources:
          limits:
            cpu: 200m
            memory: 200Mi
          requests:
            cpu: 25m
            memory: 25Mi
        readinessProbe:
          httpGet:
            path: /kube-system/healthz
            port: 9999
          initialDelaySeconds: 5
          timeoutSeconds: 5

WorkerノードのSecurity Group設定変更

今回はskipperをつかうことにしたので、kube-ingress-aws-controllerが作成したALBからアクセスする先はskipper(がいるEC2インスタンス)になります。

Security GroupへALBからskipperがいるEC2インスタンスへの通信をブロックしたままだとGateway Timeoutになってしまいます。そうならないように、ALB用につくったSGから、WorkerノードのSGへの9999番ポート(kube-ingress-aws-controllerと組み合わせて使うskipperのhostPortに指定した)の通信を許可しましょう。

ALB側SGのOutboundを絞っていないのであれば、Worker側SGのInboundを追加すればOKです。

Ingressリソースの作成

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: nginx
spec:
  rules:
  - host: nginx-ingress.example.com
    http:
      paths:
      - backend:
          serviceName: nginx
          servicePort: http

---

apiVersion: v1
kind: Service
metadata:
  name: nginx
spec:
  type: ClusterIP
  ports:
  - name: http
    port: 80
    targetPort: 80
  selector:
    app: nginx

---

apiVersion: apps/v1beta1
kind: Deployment
metadata:
  name: nginx
spec:
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - image: nginx
        name: nginx
        ports:
        - containerPort: 80

ログの確認

$ k logs kube-ingress-aws-controller-6bbd8f9d6c-bcqph
2017/12/06 12:33:52 starting /bin/kube-ingress-aws-controller
2017/12/06 12:33:54 controller manifest:
2017/12/06 12:33:54     kubernetes API server:
2017/12/06 12:33:54     Cluster ID: k8s3
2017/12/06 12:33:54     vpc id: vpc-12345678
2017/12/06 12:33:54     instance id: i-07e29f841f676ca00
2017/12/06 12:33:54     auto scaling group name: k8s3-Nodepool1-MMF7MXKI9350-Workers-BZWB5IAV7JW8
2017/12/06 12:33:54     security group id: sg-8368a6fa
2017/12/06 12:33:54     private subnet ids: []
2017/12/06 12:33:54     public subnet ids: [subnet-12345678 subnet-23456789]
2017/12/06 12:33:54 Start polling sleep 30s

30秒経過後、以下のようにCloudFormationスタックが作成される。

2017/12/06 12:34:24 Found 1 ingresses
2017/12/06 12:34:24 Found 0 stacks
2017/12/06 12:34:24 Have 1 models
2017/12/06 12:34:24 creating stack for certificate "arn:aws:acm:ap-northeast-1:myawsaccountid:certificate/64f33935-05ac-4e6b-b1ae-58973d556a74" / ingress ["kube-system/nginx"]
2017/12/06 12:34:25 stack "arn:aws:cloudformation:ap-northeast-1:myawsaccountid:stack/k8s3-b9dbfe3/caf1f3a0-da81-11e7-9e21-500c28b97482" for certificate "arn:aws:acm:ap-northeast-1:myawsaccountid:certificate/64f33935-05ac-4e6b-b1ae-58973d556a74" created
2017/12/06 12:34:25 Start polling sleep 30s

作成されたAWSリソースの確認

1〜2分待ってスタックがCREATE_COMPLETE状態になれば成功。

コンソールでCloudFormationスタックのResourcesの内容を見ると、何がつくられたのかがわかる。

image.png

つくられるのは以下の4つ。

  • HTTPListener: 80番ポート用のListener
  • HTTPSListener: 443番ポート用のListener
  • LB: ALB
  • TG: kube-ingress-aws-controllerがデプロイされているノードが登録されたTargetGroup

ALB

リスナー

443番ポート用のListenerには、Ingressリソースに書いたドメインに対応するACM証明書が選択されています。

今回は*.example.com用のワイルドカード証明書を事前に用意しておいたのですが、Ingressにnginx-ingress.example.comというホスト名を設定したところ、ちゃんとワイルドカード証明書を探し出してくれました(かしこい)。

image.png

ターゲットグループ

kube-aws-ingress-controllerがデプロイされたノードのASGをコンソールでみてみると、ターゲットグループに割り当てられていました。EC2インスタンスを直接TargetGroupに登録していくような方法だとインスタンスが落ちた場合などが怖いですが、ちゃんとしてますね。

image.png

Route53 RecordSetの作成

これだけだとALBが作成されただけなので、nginx-ingress.example.comでアクセスできないはずです。

しかし、昨日デプロイしたexternal-dnsがIngressリソースとALBを検知して、勝手にRecordSetをつくってくれていました。

stern_external-dns.log
external-dns-768686fd4c-zpnlx external-dns time="2017-12-06T12:43:53Z" level=info msg="Desired change: CREATE nginx-ingress.example.com A"
external-dns-768686fd4c-zpnlx external-dns time="2017-12-06T12:43:53Z" level=info msg="Desired change: CREATE nginx-ingress.example.com TXT"
external-dns-768686fd4c-zpnlx external-dns time="2017-12-06T12:43:53Z" level=info msg="Record in zone example.com. were successfully updated"

image.png

ちゃんとALBへのA(lias)レコードを作成してくれていますね。

 インターネットからアクセスしてみる

nginx-ingress.example.comにブラウザからアクセスしてみて、以下のようなnginxのウェルカムページが表示されてば成功です。おつかれさまでした。

image.png

まとめ

kube-ingress-aws-controllerを使うと、Kubernetesユーザはkubectl createするだけでALBとRecordSetをよしなにセットアップしてくれます。
ALBの作成・管理やRoute53 RecordSetの作成のためにいちいちインフラエンジニアを呼び出したくない!というようなセルフサービス好きの会社さんでは特に役立つのではないでしょうか?!

トラブルシューティング

unable to get details for instance “i-0346d738155e965d8”

IAMポリシーが足りないときに出るエラーです。

$ k logs kube-ingress-aws-controller-7f7974ff58-6bvv8
2017/12/06 07:11:51 starting /bin/kube-ingress-aws-controller
2017/12/06 07:11:53 unable to get details for instance "i-0346d738155e965d8": NoCredentialProviders: no valid providers in chain. Deprecated.
    For verbose messaging see aws.Config.CredentialsChainVerboseErrors

required security group was not found

Security Groupがないか、またはSecurityGroupのタグが間違っているか、EC2インスタンスにkubernetes.io/cluster/クラスタ名=ownedというタグがついていない場合のエラーです。

$ k logs kube-ingress-aws-controller-7f7974ff58-xqgrq
2017/12/06 08:10:40 starting /bin/kube-ingress-aws-controller
2017/12/06 08:10:41 required security group was not found

CloudFormationで「At least two subnets in two different Availability Zones must be specified」

KubernetesのWorkerノードのASGが単一のAZに割り当てられているときのエラー。ALBの仕様で、最低2つのAZが必要。

kube-ingress-aws-controller-7f7974ff58-rp7t8 controller 2017/12/06 12:01:06 Start polling sleep 30s
kube-ingress-aws-controller-7f7974ff58-rp7t8 controller 2017/12/06 12:01:36 Found 1 ingresses
kube-ingress-aws-controller-7f7974ff58-rp7t8 controller 2017/12/06 12:01:37 Found 0 stacks
kube-ingress-aws-controller-7f7974ff58-rp7t8 controller 2017/12/06 12:01:37 Have 1 models
kube-ingress-aws-controller-7f7974ff58-rp7t8 controller 2017/12/06 12:01:37 creating stack for certificate "arn:aws:acm:ap-northeast-1:myaccountid:certificate/64f33935-05ac-4e6b-b1ae-58973d556a74" / ingress ["kube-system/nginx"]
kube-ingress-aws-controller-7f7974ff58-rp7t8 controller 2017/12/06 12:01:37 stack "arn:aws:cloudformation:ap-northeast-1:myaccountid:certificate:stack/k8s3-b9dbfe3/360e1830-da7d-11e7-99f7-500c596c228e" for certificate "arn:aws:acm:ap-northeast-1:myaccountid:certificate:certificate/64f33935-05ac-4e6b-b1ae-58973d556a74" created

image.png

instance is missing the “aws:autoscaling:groupName” tag

ASG以外で作ったEC2インスタンスにkube-ingress-aws-controllerがデプロイされてしまったときのエラー。

$ k logs kube-ingress-aws-controller-7f7974ff58-m6ss2
2017/12/06 12:25:59 starting /bin/kube-ingress-aws-controller
2017/12/06 12:25:59 instance is missing the "aws:autoscaling:groupName" tag

kube-aws-ingress-controllerは、デフォルトではASGに設定されたSubnetをALBのSubnetに流用する。
そのためにASGを探すとき、EC2インスタンスについたaws:autoscaling:groupNameというASGが自動的につけてくれるタグをヒントにするため、ASG以外でつくったEC2インスタンスではこのエラーが出てしまう。

Ref: Spot Fleet support · Issue #105 · zalando-incubator/kube-ingress-aws-controller

Issueも出ているが、まだASG以外は対応していない。ワークアラウンドとしては、kube-ingress-aws-controllerのaffinityでASGでつくったノードにだけスケジュールされるようにすることが考えられる。

kube-awsの場合、awsNodeLabels機能をオンにすると、ASGでつくったノードには”kube-aws.coreos.com/autoscalinggroup”というラベルが付与されるので、それを前提にすると以下のようなaffinityをかけばOK。

              affinity:
                nodeAffinity:
                  requiredDuringSchedulingIgnoredDuringExecution:
                    nodeSelectorTerms:
                    - matchExpressions:
                      - key: "kube-aws.coreos.com/autoscalinggroup"
                        operator: "Exists"

504 Gateway Time-out

ALB経由でnginxにアクセスしようとしてこのエラーがかえってきた場合、ALB用につくったセキュリティグループからkube-aws-ingress-controllerが動いているEC2インスタンスへのアクセスを許可できていない可能性があります。

EC2インスタンス側のSGに、ALB用SGからの9999番ポート(kube-ingress-aws-controllerと組み合わせて使うskipperのhostPortに指定した)への通信をを許可するようなInboundルールを追加しましょう。

続きを読む

Node-REDをAWS API Gateway + lambda + S3で動かす方法

はじめに

2017年 Node-RED Advent Calendarの3日目の記事です。

2015年 Node-RED Advent Calendarで@stomiaさんが Node-REDをAWS Lambdaで動かす話 という記事を書かれています。
この方式はS3やSESなどのトリガーで動かせるというメリットがありますが、HTTP inノードの利用を諦める+専用ノードを使ってフローを作る必要がありました。

今回の記事は、HTTP inノードが利用できる状態でNode-REDをlambdaで動かす方法です。
これによってHTTP inノードを使ったフローを修正なしでlamdbaで動かせるようになります。
(デメリットとしてAPI Gateway以外のトリガーでは動かせません。)

また、フロー更新のたびに毎回flow.jsonを含んだNode-REDをlambdaにデプロイする構成では、デプロイ時間がネックで修正サイクルがリズムよく回せません。
これでは開発効率が悪いので、flow.json ファイルはS3に置いてフロー更新はS3のファイルを更新すれば済む構成にします。

構成図

今回の構成は下図のとおりです。この記事では青点線範囲の設定方法を説明します。
node-red-on-lambda-with-api-gw-overview.png

準備するもの

  • AWSアカウント
  • AWS CLI が使える端末(AWSアクセスキー他が設定済みであること)

    $ export AWS_ACCESS_KEY_ID=AKIAXXXXXXXXXXXXXXXX
    $ export AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    $ export AWS_DEFAULT_REGION=us-west-2
    
  • ブラウザ

設定方法

  • 所要時間参考:約10分
  1. GitHubからaws-serverless-node-red リポジトリをクローンします

    $ git clone https://github.com/sakazuki/aws-serverless-node-red.git
    $ cd aws-serverless-node-red
    
  2. 自分のAWS環境に合わせた設定ファイルを準備します

    $ AWS_ACCOUNT_ID=123456789012
    $ S3_BUCKET=nodered12
    $ AWS_REGION=us-west-2
    $ AWS_FUNCNAME=api12
    $ AWS_STACK_NAME=Node-RED
    $ npm run config -- --account-id="${AWS_ACCOUNT_ID}" 
    --bucket-name="${S3_BUCKET}" 
    --region="${AWS_REGION}" 
    --function-name="${AWS_FUNCNAME}" 
    --stack-name="${AWS_STACK_NAME}"
    

    ※これにより次のファイルが自分のAWS環境用に更新されます。

    package.json
    simple-proxy-api.yaml
    cloudformation.yaml
    settings.js
    
  3. Node-REDと必要パッケージをインストールして、lambdaにデプロイします。

    $ npm run setup
    
  4. PC上でNode-REDを起動してフローを作成します

    $ node node_modules/.bin/node-red -s ./settings.js
    

    http://localhost:1880 にブラウザでアクセスしてNode-REDエディタでフローを作成します。
    HTTP inノードが使えることを確認するためフォームのサンプルフローを作ります。
    node-red-on-lambda-flow.png

    フローデータはこちら

    コピーして、右上のドロップメニューから[読み込み]-[クリップボード]でインポートできます

    
    [{"id":"e164ca79.d4bba8","type":"http in","z":"d540fb70.fb4658","name":"","url":"/hello","method":"get","upload":false,"swaggerDoc":"","x":170,"y":100,"wires":[["f5cc40d7.836fd"]]},{"id":"4818807c.9a996","type":"http response","z":"d540fb70.fb4658","name":"","statusCode":"","headers":{},"x":510,"y":100,"wires":[]},{"id":"f5cc40d7.836fd","type":"template","z":"d540fb70.fb4658","name":"form","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"<form method="POST">n    Input your name<input type="text" name="key1">n    <input type="submit">n</form>","output":"str","x":350,"y":100,"wires":[["4818807c.9a996"]]},{"id":"5c77ba58.b744e4","type":"http in","z":"d540fb70.fb4658","name":"","url":"/hello","method":"post","upload":false,"swaggerDoc":"","x":180,"y":180,"wires":[["4c91fba9.11ed84"]]},{"id":"4c91fba9.11ed84","type":"template","z":"d540fb70.fb4658","name":"view","field":"payload","fieldType":"msg","format":"handlebars","syntax":"mustache","template":"<h1>Hello {{payload.key1}}</h1>","output":"str","x":350,"y":180,"wires":[["4818807c.9a996"]]}]
    

  5. 動作確認(ローカル)
    デプロイしたら、次のURLにアクセスしてローカルで動作確認してください。

    http://localhost:1880/hello
    
  6. 動作確認(API Gateway)
    正常動作が確認できたら、API Gateway経由でも確認します。
    次のコマンドでAPI GatewayのApiUrlを確認します。

    $ aws cloudformation describe-stacks --stack-name ${AWS_STACK_NAME} 
    --output json --query "Stacks[*].Outputs"
    [
    [
        {
            "Description": "Invoke URL for your API. Clicking this link will perform a GET request on the root resource of your API.",
            "OutputKey": "ApiUrl",
            "OutputValue": "https://1xxx2y5zzz.execute-api.us-west-2.amazonaws.com/prod/"
        },
        {
            "Description": "Console URL for the API Gateway API's Stage.",
            "OutputKey": "ApiGatewayApiConsoleUrl",
            "OutputValue": "https://us-west-2.console.aws.amazon.com/apigateway/home?region=us-west-2#/apis/1xxx2y5zzz/stages/prod"
        },
        {
            "Description": "Console URL for the Lambda Function.",
            "OutputKey": "LambdaFunctionConsoleUrl",
            "OutputValue": "https://us-west-2.console.aws.amazon.com/lambda/home?region=us-west-2#/functions/api12"
        }
    ]
    ]
    $
    

    ブラウザで ApiUrlにアクセスして動作確認します。

    https://1xxx2y5zzz.execute-api.us-west-2.amazonaws.com/prod/hello
    

    node-red-on-lambda-with-api-gw (6).png
    動作が確認できました。

  7. 後片付け(オプション)
    この手順で作成されたものを削除する場合には以下のコマンドを実行します。

    $ npm run delete-stack
    

まとめ

  • lambdaでNode-REDのフローを動かしました
  • Node-RED on lambdaでHTTP inノードが使えるようになりました
  • フローファイル(flow.json)をS3から読み込ませました。
    • これでNode-RED本体の再デプロイをすることなく、フローファイルだけを更新できるようになり、開発や修正が簡単になりました。

Node-REDで作ったAPIアプリは、lambdaに簡単に載せられることがわかりました。
皆さんも、試してみてはどうでしょうか。

明日は

@taiponrockさんのNode-RED on IBM Cloud with Watson APIの記事です。

参考

続きを読む

SORACOM Beam(MQTT → MQTTS変換サービス) で Amazon MQ に接続する

Amazon MQにmosquitto(MQTT)とMQTT over Websocketで接続の続編です

切なる願い

Amazon MQ は 生MQTT はサポートしてないお…
でも TLSがしゃべれない非力なデバイスからも Amazon MQ 使いたいお!!

ビームを飛ばせばいいじゃない

3G/LTE通信が1日10円~ 1回線から契約できる、モノ向け通信サービスのSORACOM (CM色強い) には SORACOM Beam というデータ転送サービスがありまして、それを使って Amazon MQ に接続出来たよって話です

このデータ転送時にプロトコル変換もやってくれるのですが、変換内容に 生のMQTT → MQTTS も入ってます
要するに MQTT Proxy そんなところです (CM色強い)

手順

SORACOM の Webコンソールで “SIMグループ” を作成したら、そのSIMグループの中にある SORACOM Beam で MQTTエントリポイント を選択します

soracom-beam1.png

あとは以下のように設定していくだけです

  • プロトコル: MQTTS
  • ホスト名: Amazon MQ のダッシュボードから得てください
  • ポート番号: 8883 (ねんのため、Amazon MQ のダッシュボードで確認してください)
  • ユーザ名: Amazon MQ のダッシュボードから得てください
  • パスワード: Amazon MQ のダッシュボードから得てください

これでOKです
オプションの IMSI付与 は、ON にすると 例えば my_topic/sensor 宛てに送ると、Amazon MQ では my_topic/sensor/491023123131 と、送信元 SIM の IMSI が付与されますので、送信元を特定することがとても簡単になるのでオヌヌメです

soracom-beam2.png

確認

確認には前回同様 mosquitto と HiveMQ の MQTT over Websocketを使ってみます

ターミナル側(mosquitto_sub)の方が SORACOM Beam を使っている様子です

soracom-beam-works-with-amazon-mq.png

注目ポイント

前回mosquitto_sub の引数に --capath /etc/ssl/certs/ を入れてTLS通信にしていましたが、生MQTTで送るのでコマンドラインも減っています

SORACOM Beam を使うとこんなにメリットが;

  • そもそもTLSが使えない、非力なデバイスでMQTTやりたい
  • 通信データサイズの削減 (TLSと生の違い)
  • デバイス上の証明書更新をしなくてよい
  • Amazon MQ 側の設定変更(インスタンス作り直しに伴う endpoint の変更や、ユーザ/パスワードの変更)が発生しても、デバイス上のプログラムコード変更をしなくてよい

もっと言ってしまうと TLS実装しなくていいんです 面倒から解放されますよ (^^

これが 0.0009円/1リクエスト (in/outでそれぞれ1リクエストが発生するので、感覚的な”1回”だと 0.0018円) で使えるのだから、お得と言わざるを得ない (CM色強い)

あとがき

  • Qiitaのデザインが変わってて驚いた
  • Amazon MQ って Simple Icon マダー?
  • CM色強いですが、費用対効果は抜群なサービスです

現場からは以上です。

続きを読む

Istio Ingress on Kubernetes on AWS

自己紹介


Istio?

  • “An open platform to connect, manage, and secure microservices. https://istio.io
  • Kubernetesで動くService Meshの一つ

Istio Ingress?

  • istio-ingress-controller
  • KubernetesのIngress Controllerの一種
  • Ingress … L7ロードバランシング(の設定)
  • Ingress Controller … Ingressリソースの内容に応じてL7ロードバランサをプロビジョニングする

アーキテクチャ

image.png

ref: @kelseyhightower-san, https://github.com/kelseyhightower/istio-ingress-tutorial#architecture

  • Nginx Ingresesとかと同じ構成
  • Istio Ingress Node Pool
    • 例: インターネットに公開する場合

      • インターネットに公開したL4、L7ロードバランサ経由(Classic Load Balancer, Application Load Balancer)で、プライベートサブネット内ノードのIstio Ingress Controllerへ
      • AWS Network Load Balancer経由でパブリックサブネット内ノードのIstio Ingress Controllerへ

Istio Ingressでできないこと

以下はIstio Ingressの責任範囲外

  • AWS Security Groupの設定

    • 手動、Terraform, CloudFormation、kube-awsなどのクラスタプロビジョニングツールを使う
  • AWS ELB, ALBのプロビジョニング
    • 手動、Terraform, CloudFormationなどで管理
    • Istio Ingressと他のIngress Controllerを併用する(kube-ingress-aws-controller)
  • AWS Route53 Record Setの作成

典型的なistioのインストール手順

前提として、RBACは有効に、istio専用のネームスペースをつくり、デフォルトのzipkin以外で分散トレースするとして・・・

helm installと、立て続けにhelm upgradeを実行する必要があるところがわかりづらい。


まずhelm installでCRD(Customer Resource Definition)をつくり、

$ helm install incubator/istio --set rbac.install=true --namespace istio-system --devel

helm upgradeでCRD以外をつくる。

helm upgrade eponymous-billygoat incubator/istio --reuse-values --set istio.install=true --devel

kubectlを打つのが面倒なので、kubensでistio-systemを選択。

alias k=kubectl
alias kn=kubens

kn istio-system

istio configmapにistioが接続するzipkinのホスト名が書いてあるので、それを変更。

k edit configmap istio

デフォルトでは以下のようになっている。

# Zipkin trace collector
zipkinAddress: zipkin:9411

Stackdriverにトレースを流す場合

zipkinAddress: stackdriver-zipkin.kube-system:9411

dd-zipkin-proxy+Datadog APMにトレースを流す場合、
iptablesで$MAGIC_IP:9411->$HOST_IP:9411にredirectさせてから、

zipkinAddress: 169.254.169.256:9411

Istio Ingressを試す


Ingressのプロキシ先バックエンドサービスをデプロイする

いつものKubernetesへのDeployment + istioctl kube-inject

httpbinをデプロイする。

$ git clone git@github.com:istio/istio.git
$ cd istio/samples/httpbin
$ kn istio-ingress-test
$ k apply -f <(istioctl kube-inject -f samples/httpbin/httpbin.yaml)

k apply -f httpbin.yaml のかわりに k apply -f <(istioctl kube-inject -f httpbin.yaml)としてIstio Sidecarの定義を付け加えている。


Kubernetes Ingressリソースの作成

cat <<EOF | kubectl create -f -
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: simple-ingress
  annotations:
    kubernetes.io/ingress.class: istio
spec:
  rules:
  - http:
      paths:
      - path: /.*
        backend:
          serviceName: httpbin
          servicePort: 8000
EOF

kind: IngressとあるとおりKubenetesの一般的なIngressリソースをつくるだけ。

Istio Ingress ControllerがこのIngressを検知して、よしなにEnvoyの設定変更をしてくれる。Nginx Ingress Controllerの場合よしなに設定変更されるのがNginxという違い。


Istio Ingressからバックエンドへのルーティング

あとは、Istioで一般に使うRouteRuleを設定することで、リクエストをルーティングする。Istio “Ingress”に特別なことはない。


istioのRouteRuleとは?

RouteRuleはistio管理下のService A・B間でどういうルーティングをするかという設定を表す。

Service A —istio w/ RouteRule—> Service B


Istio Ingress + RouteRule

Istio Ingressに対してRouteRuleを設定する場合、基本的にsourceがistio-ingressになる(kubectl –namespace istio get svcするとistio-ingressというServiceがいるが、それ)。

Istio Ingress —istio w/ RouteRule—> Backend Service A


Istio Ingress + RouteRuleの例

例えば、バックエンド障害時のIstio Ingressの挙動を確認したい場合。以下のようなRouteRuleで、istio ingressから全バックエンドへのリクエストを遮断する・・・というのもルーティングの範疇。

cat <<EOF | istioctl create -f -
## Deny all access from istio-ingress
apiVersion: config.istio.io/v1alpha2
kind: RouteRule
metadata:
  name: deny-route
spec:
  destination:
    name: httpbin
  match:
    # Limit this rule to istio ingress pods only
    source:
      name: istio-ingress
      labels:
        istio: ingress
  precedence: 1
  route:
  - weight: 100
  httpFault:
    abort:
      percent: 100
      httpStatus: 403 #Forbidden for all URLs
EOF

Istio Ingressでできること

クラスタ内部向けのRouteRule同様、specに色々かいて活用すると以下のようなことができる。

  • Cookieが特定のパターンにマッチしたらサービスv2、そうでなければサービスv1にルーティング

    • ユーザがアクセスするWebサービス、Web APIのA/Bテスト
    • β版Web UIの先行公開
  • バックエンドの重み付け(route[].weight)
    • ミドルウェアやライブラリの更新を一部リクエストで試したい
  • レートリミット
    • 重いWeb APIに全体で100 req/sec以上のリクエストが送られないように
  • リトライ(httpReqRetries)
    • 不安定なサービスへのリクエストが失敗したら、Istio Ingressレイヤーでリトライ。クライアント側でリトライしなくて済む
  • タイムアウト(httpReqTimeout)

あわせて読みたい: Istio / Rules Configuration


まとめ

  • 同じL7のALBなど単体ではできないL7ロードバランシングが実現できます
  • User-Facingなサービスへのデプロイを柔軟にできて、アプリやミドルウェアの更新起因の大きな障害の予防になる

AWSの場合は使わなさそうな機能

使わなくていいものをうっかり使ってしまわないように。


Secure Ingress

https://istio.io/docs/tasks/traffic-management/ingress.html#configuring-secure-ingress-https

Istio IngressがTLS終端してくれる機能。SNIに対応してない(Envoyレベルで)とか、証明書の管理が面倒(AWS ACMよりも)などの点から、使うことはなさそう。


あわせて読みたい

続きを読む

IBM Cloudライト・アカウントでWatsonをさわってみた

無料で利用できるIBM Cloudライト・アカウントがリリースされたので、Watsonをさわってみました。

ライトアカウントで利用できるAPI

全APIではないですが、ある程度利用できるようです。

  • Conversation
  • Discovery
  • Language Translator
  • Natural Language Understanding
  • Personality Insights
  • Speech To Text
  • Text to Speech
  • Tone Analyzer

Language Translator APIを試してみる

APIの認証はBasic認証です。
チュートリアルAPIドキュメントにcurlコマンドのサンプルがのっているので試してみました。

1. Language Translator APIをコンソールから作成する

仕組みが違うからなのでしょうが、AWSに慣れているとちょっと面倒な手順だと感じます。

2. 認証情報を確認する

作成したAPIのサービス資格情報からusernameとpasswordを取得します。

3. curlコマンドでAPIを叩く

英語(en)からスペイン語(es)に変換されます。

リクエスト
USERNAME=hogehoge
PASS=fuga
curl -X POST --user "$USERNAME":"$PASS" 
--header "Content-Type: application/json" 
--header "Accept: application/json" 
--data '{"text":"Hello, world!","source":"en","target":"es"}' 
"https://gateway.watsonplatform.net/language-translator/api/v2/translate"
レスポンス
{
  "translations" : [ {
    "translation" : "Hola, mundo!"
  } ],
  "word_count" : 2,
  "character_count" : 13
}

proxyとしてAmazon API Gatewayを利用してみる

1. API GatewayでAPIを作成する

1_create_api.png

2. APIのリソースを作成する

2_create_resource.png

3. proxyを設定する

文字が切れている部分は{proxy}です。
3_proxy.png

4. Basic認証用のヘッダーを設定する

Basic認証用のヘッダーを設定します。
USER/PASSのエンコードはこちらの記事を参考にさせて頂きました。
4_authorization.png

5. APIをテストする

APIをデプロイする前に、テストしてみます。
5_1_test.png
5_2_test.png

6. テスト結果

「こんにちは」が日本語(ja)と判定されました。
5_3_result.png

あとはIAM等を設定してAPIをデプロイすれば、AWSからWatsonのAPIを叩きまくれますね。

感想

東京リージョンまだー?

続きを読む

Serverless FrameworkでExpressを動かす(serverless-httpを使用)

Serverless Frameworkの公式ブログで Deploy a REST API using Serverless, Express and Node.jsという記事を見つけたので、この記事で紹介されているserverless-httpとういミドルウェアを使って、Serverless上でExpressを動かしてみました。

環境

 $ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.12.6
BuildVersion:   16G29
$ node -v && npm -v
v8.8.1
5.4.2

手順

AWS CLIの事前設定とserverlessのインストール

AWS CLIをインストールし、認証情報を登録しておきます。
AWS – Credentials を参考に、使用するAWSアカウントの情報をDefaultプロファイルに設定します。

serverlessをグローバルにインストールしておきます。

$ npm install serverless -g

ココらへんは、以前書いた記事 (serverlessでLambdaのローカル開発環境を整える)でも紹介していますので、こちらを参照してください。

プロジェクトを作成

$ mkdir serverless-express-sample
$ cd serverless-express-sample
$ npm init -f

必要なパッケージのインストール

今回は、次のnpmパッケージを使用します。
* express
* serverless-http

serverless-httpはLambdaでexpressのようなhttpをルーティングするソフトウェアを使用できるようにするミドルウェアです。

$ npm i -S express serverless-http

サンプルAPIの作成

//hogeにアクセスすると、それぞれ違った文字列をjsonで返す簡単なAPIを作成してみます。

まずは、serverlessコマンド(エイリアス: sls)でnodejsベースのプロジェクトを作成します。

$ sls create --template aws-nodejs --name serverless-express-sample 

次に、追加されたhandler.jsを次のように変更します。
Expressを使ったことがあれば、見慣れた内容だと思います。

handler.js
'use strict';

const serverless = require('serverless-http');
const express = require('express');
const app = express();

app.get('/', function (req, res) {
  res.json({ message: 'Hello World!' });
});

app.get('/hoge', function (req, res) {
  res.json({ message: 'Hello Hoge!' });
});

module.exports.main = serverless(app);

serverless.ymlは次のように変更します。

eventsには、全てのpath、全てのhttpリクエストをAPI Gatewayの機能のLambda Proxy IntegrationでExpress側に渡すための設定を記述しています。

serverless.yml
service: serverless-express-sample

provider:
  name: aws
  runtime: nodejs6.10
  stage: dev
  region: ap-northeast-1

functions:
  app:
    handler: handler.main
    events:
      - http:
          method: ANY
          path: '/'
      - http:
          method: ANY
          path: '{proxy+}'

デプロイ

作成したサンプルAPIをデプロイします。

$ sls deploy

動作確認

デプロイ結果のendpoints:に表示されているエンドポイントにブラウザからアクセスしてみると…
次のように、Expressからjsonレスポンスが返ってくることが確認できるかと思います。

スクリーンショット 2017-10-29 17.26.06.png

スクリーンショット 2017-10-29 17.26.10.png

また、WappalyzerというChrome Extensionで確認してみると、Expressが動いていることが確認出ます。

スクリーンショット 2017-10-29 17.26.35.png

参考

https://serverless.com/blog/serverless-express-rest-api/

続きを読む