このエントリーをはてなブックマークに追加

最近ようやく Angular & TypeScript でソースコードを書き始めました。型のおかげでどのような値が入る変数なのか推測しやすくなり、とても便利です。 さらに angular-cli を使うとターミナル上でプロジェクトやコンポーネントの雛形を作る事ができるため、同じようなコードを何度も繰り返し書かなくても良くなりました。

$ ng new my-project
$ cd my-project
$ ng serve -o

たったの3行で Angular のアプリケーションが動き出しブラウザに Welcome to app!! のテキストが表示されます。 何がどう動いているのか不思議に思いませんか?

今回の記事は angular-cli のコマンドと設定を元に Angular + TypeScript のプロジェクトがブラウザ上で動作するまでに何が起こっているのかを順に解説していきます。

2017/07/21 追記
タイトルを Angular2 として公開していましたが、現在の最新バージョンである Angular@4.0 で行った検証内容だったため訂正させていただきました。 Ryota Murakami さん、ご指摘ありがとうございました。

この記事を書いた環境

  • macOS Sierra
  • node v6.9.0

補足
angular-cli のコマンドを実行するには node >= 6.9 が必要です

1) angular-cliでプロジェクトを作成してみる

まず angular-cli をインストールします。 この記事を書いた時のバージョンは @angular/cli@1.2.1 です。

$ npm install -g @angular/cli

冒頭のコマンドを実行してプロジェクトを作成してみましょう。

$ ng new my-project
$ cd my-project
$ ng serve -o

angular-cli の詳しい使い方は公式ドキュメントをご覧ください。

angular-cli/wiki
https://github.com/angular/angular-cli/wiki

ディレクトリ構成

$ ng new {PROJECT_NAME} で作成された雛形は下記のようなディレクトリ構成になっています。 TypeScript に馴染みがないとディレクトリ構成だけ見ても何がどうなっているのか理解が難しいですよね。私もそうでした。

├── README.md
├── e2e
│   └── ...
├── karma.conf.js
├── node_modules
│   └── ...
├── package.json
├── protractor.conf.js
├── src
│   ├── index.html
│   ├── main.ts
│   └── ...
├── tsconfig.json
└── tslint.json

ルートディレクトリにはパッケージのインストールやユニットテストなど、さまざまな設定ファイルが集まっています。 これらを一度に理解しようとすると混乱してしまいます。まずは angular-cli のプロジェクトで使われている TypeScript について理解しましょう。

2) TypeSciptのトランスパイルを理解する

TypeScript
https://www.typescriptlang.org/index.html

TypeScriptJavaScript を拡張した言語です。構文は JavaScript によく似ていますが変数は静的型づけされ interfaceclass などの構文を使う事ができます。

JavaScript の場合、例えば <script> タグでファイルを参照するだけでブラウザがソースコードを解釈し実行してくれますが TypeScript はブラウザが解釈する事ができません。 このため TypeScript から JavaScriptトランスパイル したものを参照する必要があります。

tsc を使ったトランスパイル

// greeter.ts
class greeter {
  greet(name: string): string {
    return "Hello, " + name;
  }
}
$ npm install -g typescript
$ tsc greeter.ts

typescript には tsc というツールが付随しています。これが TypeScriptJavaScript に変換してくれる トランスパイラ です。 ファイルパスを渡すと JavaScript にトランスパイルしたファイルが出力されます。

// greeter.js
var greeter = (function () {
  function greeter() {
  }
  greeter.prototype.greet = function (name) {
    return "Hello, " + name;
  };
  return greeter;
}());

tsc にオプションを渡す

tsc コマンドを実行した時のカレントディレクトリに tsconfig.json が存在する場合、トランスパイルのオプションとして使用されます。 angular-cli のディレクトリ構成にもこのファイルは存在していますね。

// tsconfig.json
{
  "compileroptions": {
    "outdir": "./dist/out-tsc",
    "baseurl": "src",
    "sourcemap": true,
    ...

[詳しい解説] TypeScript in 5 minutes
https://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html

tsc だけでは開発が難しい

tsc にはいくつかオプションがありますが、複数ファイルをまとめて処理したり、ひとつのファイルに集約するなどの複雑な動作はできません。 開発が進むたびに増えるファイルをひとつずつコマンドでトランスパイルするのは現実的ではないですよね。 そこで Gulpwebpack などのツールを使って開発の手助けをする必要があります。

方法その1:Gulp

開発用のタスクランナーです。 ソース(ファイルおよびディレクトリ)とそれらに対する連続した動作をタスクとして定義し gulp コマンドから呼び出します。 Gulp 自身は TypeScript のトランスパイル機能は持っていないためプラグインを組み合わせて使います。

設定を gulpfile.js に書く

// gulpfile.js

var gulp = require("gulp");
var ts = require("gulp-typescript");
var browserify = require("browserify");

// トランスパイルのオプションを読み込む
var tsProject = ts.createProject("tsconfig.json");

gulp.task("default", function () {
    return tsProject.src() // 対象となるソース
        .pipe(tsProject()) // トランスパイラにソースを渡す
        .js // トランスパイル
        .pipe(browserify({})) // ひとまとめにする
        .pipe(gulp.dest("dist/bundle.js")); // ファイル出力
...

ターミナルで実行

$ gulp
# ---> dist/bundle.js が出力される

[詳しい解説] TypeScript Tutorials Gulp
https://www.typescriptlang.org/docs/handbook/gulp.html

方法その2:webpack

アセット(JavaScript・HTML・CSS)を読み込んでバンドルする(ひとまとめにする)ツールです。 Gulp と同じように TypeScript のトランスパイル機能は持っていないため、ローダーとプラグインの組み合わせでトランスパイルを行います。

設定を webpack.conf.js に書く

// webpack.conf.js
module.exports = {
    entry: "./src/index.ts", // エントリポイント、このファイルから依存関係を辿る
    module: {
      rules: {
        "test": /\.ts$/, // 拡張子が.tsだったらローダーを使って読み込む
        "loader": "awesome-typescript-loader" // 読み込んだ時にトランスパイル
      },
    },
    output: {
        filename: "bundle.js", // ファイル出力
        path: __dirname + "/dist"
    },
    ...

ターミナルで実行

$ webpack
# ---> dist/bundle.js が出力される

[詳しい解説] TypeScript Tutorials React & Webpack
https://www.typescriptlang.org/docs/handbook/react-&-webpack.html

3) Angularはどのようにトランスパイルしているのか

ここまでで TypeScript がどのようにトランスパイルされるのかをご理解いただけたと思います。 では Angulartsc でトランスパイルしているのでしょうか?それとも Gulpwebpack

もう少し探るために $ ng serve コマンドの動作を追いかけてみましょう。

ng コマンドの正体

$ ng って何でしょうか。まずはここから探ってみます。

$ which ng
$ /usr/local/bin/ng

私の環境では ng コマンドは上記の場所に格納されていました。 このファイルは Node で実行される JavaScript のソースコードのため、テキストエディタで開いて読む事ができます。

ng serve

ソースコードを眺めると $ ng serve を実行した時に以下のような順番でファイルが呼び出されていました。

  • {$NODE_PATH}/@angular/cli/bin/ng
  • {$NODE_PATH}/@angular/cli/commands/serve.js
  • {$NODE_PATH}/@angular/cli/tasks/serve.js

3番目の tasks/serve.js のソースコードに注目してみます。

// {$NODE_PATH}/@angular/cli/tasks/serve.js

const WebpackDevServer = require('webpack-dev-server');
...
const server = new WebpackDevServer(webpackCompiler, webpackDevServerConfiguration);
return new Promise((_resolve, reject) => {
    server.listen(serveTaskOptions.port, serveTaskOptions.host, (err, _stats) => {
        ...
    });
})

webpack-dev-server のインスタンスを作成し接続を開いている事が分かります。

webpack-dev-serverNode 製の開発用サーバーです。 ローカルでwebサーバーを立ち上げ開発中のアプリケーションを動作させることができ、ソースコードの変更に伴うオートリロードなどの機能を持ちます。

webpack DevServer
https://webpack.js.org/configuration/dev-server/#components/sidebar/sidebar.jsx

つまり $ ng serve コマンドは下記のような動作をしている事になります。

webpack.conf.js はどこに?

webpackwebpack.conf.js に設定を定義するのが一般的です。 Angular もこのファイルを持っているのでしょうか?

ディレクトリ構成を見る限り webpack.conf.js は見当たらず $ ng serve は特定の webpack.conf.js を読み込んでいる訳ではないようです。

$ ng eject コマンドでスケルトンを取り出す機能が提供されているため、今回はこちらを使って webpack でどのような動作が行われるのかを調べてみましょう。

注意

eject コマンドを実行した後はそのプロジェクトにおいて $ ng serve コマンドを使うことができなくなります。 webpack.conf.js を開発者自身でカスタマイズし $ npm start でブラウザが開くように変更が加えられるためです。 この点にご注意ください。

$ ng eject
// package.json

"scripts": {
  "ng": "ng",
-  "start": "ng serve",
-  "build": "ng build",
-  "test": "ng test",
+  "start": "webpack-dev-server --port=4200",
+  "build": "webpack",
+  "test": "karma start ./karma.conf.js",
  ...
  "devDependencies": {
    "@angular/cli": "1.2.0",
    ...
    "typescript": "~2.3.3"
+    "webpack-dev-server": "~2.4.5",
+    "webpack": "~2.4.0",
+    "autoprefixer": "^6.5.3",
+    "css-loader": "^0.28.1",
    ...

eject コマンド実行後にルートディレクトリを確認すると webpack.conf.js が追加されています。 このファイルには Angular がどのように処理を行なっているのかが記述されています。

4) webpack.conf.js のスケルトンを探る

$ ng eject コマンドで取り出した webpack.conf.js に書かれた内容を探っていきましょう。 450行とボリュームの大きいファイルですので部分的に引用して紹介していきます。また説明のために実際のファイルとは異なる順番になっていますのでご了承ください。

各設定項目の詳細は下記をご覧ください。

webpack configuration
https://webpack.js.org/configuration/

require

const fs = require('fs');
const path = require('path');
const ProgressPlugin = require('webpack/lib/ProgressPlugin');

webpackNode で実行されるため設定ファイルは CommonJS 形式で記述されています。 require() は他のファイルから関数やオブジェクトをインポートする機能で、以下のいずれかを文字列で渡します。

  • Node のビルトインモジュール名
  • node_modules/ にインストールされたパッケージ名
  • ファイルパス

module.exports

module.exports = {
  ...(設定内容)
}

設定はオブジェクトリテラルで定義し module.exports で他のファイルから参照できるようにします。 これも CommonJS 形式の記述です。

entry

"entry": {
  "main": [
    "./src/main.ts"
  ],
  "polyfills": [
    "./src/polyfills.ts"
  ],
  "styles": [
    "./src/styles.css"
  ]
},

アプリケーションのエントリポイントを定義します。 オブジェクトが指定された場合はキーの数だけ Dependency Graph が作成され、それぞれのファイルを再帰的に辿ってファイル間の依存関係をツリー構造に分析します。 先頭の main.tsAngular のトップレベルの依存が記述されたファイルです。

resolve.modules

"resolve": {
  "modules": [
    "./node_modules",
    ...
  ],

依存関係を辿る時に、パッケージはこのフォルダから検索されます。 TypeScript のソースコードと照らし合わせて説明すると import { A } from 'foo' と記述されている場合 node_modules/foo が検索されるイメージです。

resolve.extensions

"resolve": {
  "extensions": [
    ".ts",
    ".js"
  ],

インポート文に拡張子が記述されない場合は、この拡張子を使ってファイル名の解決を試みます。 TypeScript の ソースコードでimport { A } from './foo' と記述されている場合 foo.ts foo.js の順番で検索され最初に一致したファイルが読み込まれます。

angular-cli で作成したプロジェクトの雛形は TypeScript のソースコードと JavaScript のパッケージで構成されているため .ts および .js が検索されるように設定されています。

module.rules

"module": {
  "rules": [
  {
    "test": /\.html$/,
    "loader": "raw-loader"
  },
  {
    "test": /\.css$/,
    "use": [
      {
        "loader": "css-loader",
        "options": ...
      },
      {
        "loader": "postcss-loader",
        "options": ...
      }
    ],
  },
  {
    "test": /\.ts$/,
    "loader": "@ngtools/webpack"
  }
  ...
],

解決したファイルは、拡張子ごとにローダーが割り振られ読み込まれます。 test にパターンマッチすると loader が割り振られる仕組みで、それぞれのローダーは node_modules/ から検索されます。

拡張子 .ts@ngtools/webpack という Angular 専用のローダーが読み込んでいます。 このローダーがトランスパイルを行なっているはずです。 ソースコードを見てみましょう。

// node_modules/@ngtools/webpack/src/loader.js

Object.defineProperty(exports, "__esModule", { value: true });
const path = require("path");
const ts = require("typescript");
const plugin_1 = require("./plugin");
...
  if (plugin && plugin instanceof plugin_1.AotPlugin) {
    const refactor = new refactor_1.TypeScriptFileRefactor(sourceFileName, plugin.compilerHost, plugin.program, source);
    ...
    const result = refactor.transpile(compilerOptions);
    ...
  }
  else {
    ...
    const compilerHost = ts.createCompilerHost(compilerOptions);
    ...
  }

それっぽい箇所だけ抜き出してみました。 require() で読み込んだ typescript モジュールは Compiler API という機能を提供しています。 @ngtools/webpack はこの機能を利用して、2通りのトランスパイルを行なっています。

typescript Using the Compiler API
https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API

plugin

ビルドパイプラインを指定します。トランスパイルされたソースコードは上から順に次々にプラグインに渡され、処理されていきます。

"plugins": [
  ...
  new HtmlWebpackPlugin({
    "template": "./src/index.html",
    "filename": "./index.html",
    ...
  }
  }),
  new BaseHrefWebpackPlugin({}),
  ...
  new AotPlugin({
    "mainPath": "main.ts",
    ...
    "exclude": [],
    "tsConfigPath": "src/tsconfig.app.json",
  })
  ...
],

HtmlWwebpackPlugin
https://github.com/jantimon/html-webpack-plugin

HTMLを生成するプラグインです。tempalte から読み込んだものを filename に出力しています。 index.htmlAngular アプリケーションが起動した時にブラウザに一番最初に表示されるページですね。

BaseHrefWebpackPlugin
https://github.com/dzonatan/base-href-webpack-plugin

アプリケーションのURIの基準になる <BASE> タグを出力します。 Angular アプリケーションでは <BASE> タグの設置が必須となっています。

HTMLに関するプラグインは他にも定義されていますが、これらを組み合わせる事により以下のようなHTMLが動的に生成されます。

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>MyProject</title>
  <base href="/">
  ...
</head>
<body>
  <app-root></app-root>
  ...
  <script type="text/javascript" src="main.bundle.js"></script></body>

AotPlugin
https://www.npmjs.com/package/@ngtools/webpack

さきほど @ngtools/webpack のソースコードに記述されていた AotPlugin が出てきました。

Angular には just-in-time (JIT)ahead-of-time (AOT) の2種類のコンパイラが存在します。 両者ともテンプレートのコンパイルを行うものですが、コンパイルを行うタイミングにより用途が分かれます。 それぞれの違いについて軽く理解しておきましょう。

詳細は公式ドキュメントをご覧ください
https://angular.io/guide/aot-compiler

JITコンパイラ

ブラウザからランタイムで呼ばれるコンパイラです。 コンパイル結果はファイル出力されずメモリ上でやり取りします。 ランタイムのため何度もコンパイル処理が実行されますが、修正がリアルタイムに反映されるメリットがあり開発環境で使用されます。

AOTコンパイラ

任意のタイミングで呼び出されるコンパイラです。 コンポーネントのテンプレートもコンパイラのチェック対象となり、記述ミスがある場合はコンパイルが失敗します。 コンパイル結果はファイル出力され、次にコンパイルが実行されるまで上書きされません。

コンパイラの使い分け

  • $ ng serve ローカル開発用コマンド、JITコンパイラが使われます。
  • $ ng serve --aot AOTコンパイラを使うようにオプション指定できます。
  • $ ng build プロダクション環境に配布するファイルを生成、AOTコンパイラが使われます。

output

"output": {
  "path": path.join(process.cwd(), "dist"),
  "filename": "[name].bundle.js",
  ...
},

webpack が処理した結果をファイル出力します。 [name] はプレースホルダでエントリポイントのキー名で埋められます。

まとめ

$ ng new my-project
$ cd my-project
$ ng serve -o

コマンドを実行してからブラウザが起動するまでの数秒の間に、何が行われているのかご理解いただけましたでしょうか。 簡単にまとめると、下図のように Angularwebpack を使って TypeScript のトランスパイルからファイルのバンドルまでを行い、またテンプレートなど独自のシンタックスを解析するためにオリジナルのプラグインを呼び出すように設定されています。

ローカル開発環境

プロダクション環境用の配布ファイル

かくいう私も Angular を触ったばかりですので、認識の間違いやドキュメントの読み違えがあるかもしれません。 間違いに気づかれましたら記事訂正のためご指摘をいただけると有難いです。

仕組みを理解する事は開発を進める上で大きな助けになると思っています。 Angular をこれから学習する方、またはトラブル解決の小さな助けになれば幸いです。


このエントリーをはてなブックマークに追加

CloudFormation、便利ですよね。インフラのコード化はインフラの状態の変化を可視化できるというたいへん素晴らしいメリットがあります。くわえてCloudFormationはスタック単位で作成・削除ができるため、WEBコンソールから手動で作成・削除するときに起こりうる「不要なリソースが残ったまま」や「必要なリソースを削除してしまう」というリスクが減ることで心理的・金銭的なコストが軽減される点がわたしは気に入っています。

さて当エントリでは「CloudFormationを使ってEC2インスタンスを立ち上げる」という基本的な作業において気になったUbuntuの起動システムについて調査した経過を記します。

前提

本番環境で稼働しているUbuntuベースのインスタンス(以後「稼働インスタンス」と呼びます)が存在します。この稼働インスタンスと同等のインスタンス(以後「テストインスタンス」と呼びます)を作成し、あるテストをおこないたいと思います。 そこで、稼働インスタンスのAMIをWEBコンソールから作成し、このAMIをもとにテストインスタンスをCloudFormationから作成することにしました。この際、稼働インスタンスでは各種デーモンが動いているのですがテストインスタンスにおいてはこれらを動かしたくありません。

どうする?

CloudFormationには各種ヘルパースクリプトが用意されています。

ヘルパースクリプトは Amazon Linux AMI の最新バージョンにプレインストールされています。他の UNIX/Linux AMI で使用するために、Amazon Linux yum リポジトリから入手することもできます。

http://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/cfn-helper-scripts-reference.html

これらはAmazon Linux AMIですとプレインストールされていますがUbuntuベースのAMIですと意図的にインストールしてやる必要があります。 このヘルパースクリプトのうちcfn-initというスクリプトを使えばテストインスタンス起動時のデーモンの起動を無効にすることができそうです。

cfn-init ヘルパースクリプトは、AWS::CloudFormation::Init キーからテンプレートメタデータを読み取り、それに応じて次のような操作を行います。

  • CloudFormation のメタデータの取得と解析
  • パッケージのインストール
  • ディスクへのファイルの書き込み
  • サービスの有効化/無効化と開始/停止

http://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/cfn-init.html

具体的にはAWS::CloudFormation::Initキーにおいてservicesキーを定義することでサービスの起動設定を行えるようです。 このとき、

Linux システムでは、このキーは sysvinit を使用してサポートされています。

http://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-init.html#aws-resource-init-services

とありますが、ここで sysvinit とはなにを意味するのか?よくわかりませんので調べてみました。

sysvinit とは?

sysvinitはSystemV initの略で,UNIX SystemV(システムファイブ)と呼ばれるAT&T社謹製の古典的なUNIXが採用した起動メカニズムと同じ動作をするように設計されたソフトウェアです。

http://gihyo.jp/dev/serial/01/sc-literacy/0013

とありました。さらに調べるとこの仕組みはUbuntuにおいてはUbuntu 6.10 (Edgy Eft)においてUpstart に、 Ubuntu 15.04 (Vivid Vervet)においてsystemd に取って代わられたとのことです。 ということは、Ubuntuにおける直近のLTSリリースである14.0416.04sysvinitをサポートしていないのでしょうか?これではcfn-initは使えません。 とりあえずトライしてみることにしました。

cronデーモンを例にトライ

以下のようなテンプレートを用意しました。少々長いですが引用します。

 1 ---
 2 Description: sysvinit test
 3 Parameters:
 4   KeyName:
 5     Type: String
 6   VpcId:
 7     Type: String
 8   SubnetId:
 9     Type: String
10   Image:
11     Description: AMI
12     Type: String
13     AllowedValues:
14     - ami-8c4055eb # Ubuntu Server 14.04
15     - ami-785c491f # Ubuntu Server 16.04
16     Default: ami-785c491f # Ubuntu Server 16.04
17 Resources:
18   WebAccessGroup:
19     Type: AWS::EC2::SecurityGroup
20     Properties:
21       GroupDescription: Enable outgoing HTTP(S) access
22       SecurityGroupEgress:
23       - IpProtocol: tcp
24         FromPort: '80'
25         ToPort: '80'
26         CidrIp: 0.0.0.0/0
27       - IpProtocol: tcp
28         FromPort: '443'
29         ToPort: '443'
30         CidrIp: 0.0.0.0/0
31       VpcId: !Ref VpcId
32   SSHGroup:
33     Type: AWS::EC2::SecurityGroup
34     Properties:
35       GroupDescription: Enable global SSH access
36       SecurityGroupIngress:
37       - IpProtocol: tcp
38         CidrIp: 0.0.0.0/0
39         FromPort: 22
40         ToPort: 22
41       SecurityGroupEgress:
42       - IpProtocol: tcp
43         CidrIp: 0.0.0.0/0
44         FromPort: 22
45         ToPort: 22
46       VpcId: !Ref VpcId
47   TestServer:
48     Type: AWS::EC2::Instance
49     Properties:
50       ImageId: !Ref Image
51       InstanceType: t2.micro
52       KeyName: !Ref KeyName
53       AvailabilityZone: ap-northeast-1a
54       SubnetId: !Ref SubnetId
55       SecurityGroupIds:
56         - !Ref SSHGroup
57         - !Ref WebAccessGroup
58       UserData:
59         Fn::Base64:
60           Fn::Sub:
61           - |
62             #!/bin/bash -xe
63             apt-get update
64             apt-get -y install python-pip
65             pip install https://s3.amazonaws.com/cloudformation-examples/aws-cfn-bootstrap-latest.tar.gz
66             cp -a /usr/local/init/ubuntu/cfn-hup /etc/init.d/cfn-hup
67
68             chmod u+x /etc/init.d/cfn-hup
69             /usr/local/bin/cfn-init -v \
70                      --stack ${StackName} \
71                      --region ${Region} \
72                      --resource TestServer
73           - StackName: {Ref: "AWS::StackName"}
74             Region: {Ref: "AWS::Region"}
75     Metadata:
76       AWS::CloudFormation::Init:
77         config:
78           services:
79             sysvinit:
80               cron:
81                 enabled: 'false'
82                 ensureRunning: 'false'

このテンプレートにおいて今回の肝は58~74行目のUserDataキーと75行目以降のMetadataキーになります。 UserDataにてcfn-initヘルパースクリプトをインストールし、停止したいサービスを列挙したMetadataキー配下をcfn-init使用時に指定することで、テストインスタンス起動時にcronデーモンを停止することを目的としています。

たとえばcfn-initを作動させた状態でUbuntu 14.04テストインスタンスを立ち上げたいときはaws-cliを用いて以下のように実行することで試すことができます。ImageパラメータにUbuntu 14.04のAMIを指定していることに注意してください。

$ aws cloudformation create-stack \
    --stack-name cfn-init-test-ubuntu-14-cron-off \
    --template-body file:///path/to/cfn-init-test.template \
    --parameter ParameterKey=Image,ParameterValue=ami-8c4055eb \
                ParameterKey=KeyName,ParameterValue=key-name \
                ParameterKey=VpcId,ParameterValue=vpc-id \
                ParameterKey=SubnetId,ParameterValue=subnet-id

同様にcfn-initを作動させない状態でUbuntu 16.04テストインスタンスを立ち上げたいときは58行目以降を削除したのちに、

- 58       UserData:
- 59         Fn::Base64:
- 60           Fn::Sub:
- 61           - |
- 62             #!/bin/bash -xe
- 63             apt-get update
- 64             apt-get -y install python-pip
- 65             pip install https://s3.amazonaws.com/cloudformation-examples/aws-cfn-bootstrap-latest.tar.gz
- 66             cp -a /usr/local/init/ubuntu/cfn-hup /etc/init.d/cfn-hup
- 67
- 68             chmod u+x /etc/init.d/cfn-hup
- 69             /usr/local/bin/cfn-init -v \
- 70                      --stack ${StackName} \
- 71                      --region ${Region} \
- 72                      --resource TestServer
- 73           - StackName: {Ref: "AWS::StackName"}
- 74             Region: {Ref: "AWS::Region"}
- 75     Metadata:
- 76       AWS::CloudFormation::Init:
- 77         config:
- 78           services:
- 79             sysvinit:
- 80               cron:
- 81                 enabled: 'false'
- 82                 ensureRunning: 'false'

先述のようにaws-cliを用いて以下のように実行します。ImageパラメータにUbuntu 16.04のAMIを指定していることに注意してください。

$ aws cloudformation create-stack \
    --stack-name cfn-init-test-ubuntu-16-cron-off \
    --template-body file:///path/to/cfn-init-test.template \
    --parameter ParameterKey=Image,ParameterValue=ami-785c491f \
                ParameterKey=KeyName,ParameterValue=key-name \
                ParameterKey=VpcId,ParameterValue=vpc-id \
                ParameterKey=SubnetId,ParameterValue=subnet-id

ちなみにスタックが作成されるとWEBコンソールから以下のような表示が見られると思います。 cloudformation_

このように、各バージョンのUbuntuにおいてcfn-initの有効・無効を切り替えて試してみました。 結果、cfn-initを作動させた場合はcronデーモンは停止状態で、cfn-initを作動させない場合はcronデーモンは稼働状態でした。 これはUbuntu 14.04Ubuntu 16.04の双方で同様の現象でした。これらはsysvinitを採用していないはずなのになぜでしょう? まずは各々の実際に稼働した起動システムを調べてみます。

Ubuntu 14.04 の起動システム

ubuntu14$ sudo stat /proc/1/exe
  File: '/proc/1/exe' -> '/sbin/init'
  Size: 0               Blocks: 0          IO Block: 1024   symbolic link
Device: 3h/3d   Inode: 9323        Links: 1
Access: (0777/lrwxrwxrwx)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2017-06-28 07:43:10.904461000 +0000
Modify: 2017-06-28 06:03:36.184461000 +0000
Change: 2017-06-28 06:03:36.184461000 +0000
 Birth: -
ubuntu14$ /sbin/init --version
init (upstart 1.12.1)   # <= upstart
Copyright (C) 2006-2014 Canonical Ltd., 2011 Scott James Remnant

This is free software; see the source for copying conditions.  There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
ubuntu@ip-172-31-25-228:~$

Ubuntu 16.04 の起動システム

ubuntu16$ sudo stat /proc/1/exe
  File: '/proc/1/exe' -> '/lib/systemd/systemd'   # <= systemd
  Size: 0               Blocks: 0          IO Block: 1024   symbolic link
Device: 4h/4d   Inode: 9340        Links: 1
Access: (0777/lrwxrwxrwx)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2017-06-28 06:13:46.476000000 +0000
Modify: 2017-06-28 06:13:46.472000000 +0000
Change: 2017-06-28 06:13:46.472000000 +0000
 Birth: -

調べたところ予想どおりUbuntu 14.04ではUpstartが、Ubuntu 16.04ではsystemdが採用されていることがわかります。 どうしてsysvinitでないのにcfn-initは所望の動作をしてくれたのでしょうか?

cfn-initのソースを追っていくとサービスの有効・無効化にはupdate-rc.dを使用している ことがわかりました。 ログからもその動作が垣間見えます。

# /var/log/cfn-init.log@ubuntu14
2017-06-28 06:14:28,533 [DEBUG] Using service modifier: /usr/sbin/update-rc.d
2017-06-28 06:14:28,533 [DEBUG] Setting service cron to disabled
2017-06-28 06:14:28,639 [INFO] disabled service cron
2017-06-28 06:14:28,639 [DEBUG] Using service runner: /usr/sbin/service
2017-06-28 06:14:28,654 [DEBUG] Stopping service cron as it is running
2017-06-28 06:14:28,778 [INFO] Stopped cron successfully
2017-06-28 06:14:28,779 [INFO] ConfigSets completed
# /var/log/cfn-init.log@ubuntu16
2017-06-28 06:04:00,068 [DEBUG] Using service modifier: /usr/sbin/update-rc.d
2017-06-28 06:04:00,069 [DEBUG] Setting service cron to disabled
2017-06-28 06:04:00,080 [INFO] disabled service cron
2017-06-28 06:04:00,081 [DEBUG] Using service runner: /usr/sbin/service
2017-06-28 06:04:00,087 [DEBUG] Stopping service cron as it is running
2017-06-28 06:04:00,098 [INFO] Stopped cron successfully
2017-06-28 06:04:00,099 [INFO] ConfigSets completed

update-rc.dは下記のとおり、System V スタイルの起動設定をサポートしているとのこと。その意味ではcfn-initの説明と合致します。

update-rc.d updates the System V style init script links /etc/rcrunlevel.d/NNname whose target is the script /etc/init.d/name. These links are run by init when it changes runlevels; they are generally used to start and stop system services such as daemons. runlevel is one of the runlevels supported by init, namely, 0123456789S, and NN is the two-digit sequence number that determines where in the sequence init will run the scripts.

http://manpages.ubuntu.com/manpages/precise/man8/update-rc.d.8.html

残る疑問

さきほど調べたとおり今回のテストサーバの起動システムはUpstartおよびsystemdでした。sysvinitとは合致しません。どうしてcronデーモンの停止は実現したのでしょうか?またcronデーモン以外のサービスでも同様の動きになるでしょうか?

まとめ

今回はここまでです。次回は先述の疑問を解明すべくさらにいろいろ試したいと思います。


このエントリーをはてなブックマークに追加

ECSでオートスケーリングを設定/実装するために必要だった事の単なるメモです。
何かの役に立てたら幸いです。

スケールアウト

EC2のスケールアウトと同様に、CloudWatchのアラームをトリガーにしてスケールさせます。
スケール対象がオートスケーリンググループではなく、ScalableTargetを介してECSサービスのDesiredCountを変更するため、CloudFormationのテンプレートはEC2と若干異なります。

{
  "ServiceScalingTarget": {
    "Type": "AWS::ApplicationAutoScaling::ScalableTarget",
    "Properties": {
      "MinCapacity": 2,
      "MaxCapacity": 10,
      "ResourceId": { "Fn::Join": ["", [
        "service/", { "Fn::ImportValue": "SomeECSCluster" }, "/", { "Fn::GetAtt": ["SomeECSService", "Name" ]}
      ] ] },
      "RoleARN": { "Fn::GetAtt": ["SomeAutoScalingRole", "Arn"] },
      "ScalableDimension": "ecs:service:DesiredCount",
      "ServiceNamespace": "ecs"
    },
    "DependsOn": "SomeECSService"
  },
  "ServiceScalingOutPolicy": {
    "Type": "AWS::ApplicationAutoScaling::ScalingPolicy",
    "Properties": {
      "PolicyName": "SomeServerScalingOutPolicy",
      "PolicyType": "StepScaling",
      "ScalingTargetId": { "Ref": "ServiceScalingTarget" },
      "StepScalingPolicyConfiguration": {
        "AdjustmentType": "PercentChangeInCapacity",
        "Cooldown": 60,
        "MetricAggregationType": "Average",
        "StepAdjustments": [
          { "MetricIntervalLowerBound": 0, "ScalingAdjustment": 200 }
        ]
      }
    }
  },
}

EC2の場合と異なりスケールアウトする際のデプロイが必要ないため、DesiredCountを変更するだけの簡単なお仕事です。
EC2の場合はホスト数を変更する以外に以下の実装が必要になります。

  • スケールアウト時にCodeDeployなどを使ってデプロイする処理
  • デプロイ失敗時の処理
  • デプロイ成功後にELBへアタッチする処理

スケールイン

基本的にスケールアウトと同様に、CloudWatchのアラームをトリガーにしてスケールさせます。

ECSサービスがスケールインされる際にコンテナは停止されようとしますが、停止されるまでの最大待ち時間はデフォルト30秒なので注意が必要な場合があります。
もしコンテナを停止させるまで30秒以上かかる可能性がある場合は、ECS Agentに環境変数ECS_CONTAINER_STOP_TIMEOUTを渡し、待ち時間を変更する必要があります。

参考: Amazon ECS Container Agent Configuration - Amazon EC2 Container Service http://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-agent-config.html

コンテナ停止に関係する処理(スケールイン、デプロイなど)が一様に遅くなるため、終了までの時間は短く維持できるように心がけた方がよさそうです。

補足: デプロイ時に古いコンテナは新しいコンテナと交換される形で停止されるため、デプロイ速度にも影響が出ます。(コンテナは更新できない仕様)

コンテナ停止待ちによるコンテナ実行待ち

コンテナ停止待ち時間を長くする事による、デメリットの1つです。

まずECSの仕様として、「停止待ち状態」に移行したコンテナは、他のコンテナをホストにアサインする際のリソース消費量に関与しなくなるという振舞いがあります。
そのため、ホストの空きリソースがあまりない状況下では、デプロイしたコンテナが「実行待ち状態」のまま停止してしまって見える現象が起こりえます。

状況としては以下のようになっています。

  • コンテナをホストにアサインするためのリソースは足りていたため、コンテナがホストにアサインされた。
  • コンテナを実行するためのリソースがホストには不足していたため、「実行待ち状態」で待機している。
# 状態としては以下のようなイメージ
ホスト (2048MiB)
- コンテナ1 (512MiB) # 停止待ち
- コンテナ2 (512MiB) # 実行中
- コンテナ3 (512MiB) # 実行中
- コンテナ4 (512MiB) # 実行中
- コンテナ5 (512MiB) # 実行待ち

この「停止待ち状態」は、最大で「コンテナ停止待ち時間」と同じ時間だけ継続されます。

ホストのスケールアウト

ECSとEC2はお互いに殆ど関与していません。
ECSサービスがスケールアウトしようとしている時に、ECSクラスタ上に十分なリソースがなければスケールアウトできません。

ECSクラスタのリソースが不足しないように、クラスタに参加しているEC2インスタンスもスケールアウトさせる必要があります。

閾値の計算方法

基本的には以下の計算方法でスケールアウトの閾値を決定する事が出来るかと思います。

閾値 = 1 - MAX(クラスタ内のコンテナの必要リソース) / ホストのリソース

  • コンテナ1: メモリ512MiB
  • コンテナ2: メモリ256MiB
  • ホスト: 8GiB
1 - 512MiB / 8GiB = 0.9375 = 93.75%

この例だと、メモリの使用量が93.75%を上回らないようにホストを追加するようにすれば、リソースが足りずにECSサービスがスケールアウトできない状態を殆どなくす事ができます。

EC2インスタンスの起動時間があるため、閾値を85%などに下げ、早めにホストを追加する事によって、コンテナ起動までの待ち時間を最小限にできる。

ホストのスケールイン

スケールアウトの時と同様に、EC2はECSに関与しないので、コンテナが起動していようがEC2インスタンス(ホスト)がシャットダウンしてしまいます。
リクエストを捌いている最中のコンテナが停止してしまったらもちろんレスポンスは返せません。困りましたね。

これを解決するためには、ホストがスケールインによってシャットダウンされる前に、ホスト内の全てのコンテナを停止させ、ホスト内の全てのコンテナの停止を待機するよう実装する必要があります。
AWSではこの事を「コンテナインスタンス(ホスト)のドレイニング」と呼ぶようです。

ホストのドレイニング

AutoScalingのライフサイクルフック, AWS SNS, AWS Lambda を組み合わせた方法がAWSの公式ブログにまとめてあり、作業量は多めですが参考にしてうまくいきました。

参考: Amazon ECS におけるコンテナ インスタンス ドレイニングの自動化方法 | Amazon Web Services ブログ https://aws.amazon.com/jp/blogs/news/how-to-automate-container-instance-draining-in-amazon-ecs/