2014年11月20日木曜日

DartアプリケーションをGoogle App EngineのManaged VMsで動かすまで

Google App Engine(GAE)のManaged VMs(MVMs)でDockerがサポートされた。これまでLimited Previewだったが、今月のGCP Liveでオープンになった。

あわせて、GAEのMVMs上でDartアプリケーションを動かすことができるように、GoogleがDartアプリケーションのランタイムイメージを提供してくれたので、それをベースイメージにして、自分で作ったDartアプリケーションをGAEへデプロイできるようになった。

以下、GAEのMVMsって何?という全体像の把握から、DockerやDartの環境構築、アプリのデプロイまでの一連の流れをまとめてみた。

参考:



1. 全体のイメージを掴む

Dart and Google Cloud Platform | Dart: Structured web apps
まずは、GAEのMVMsでDartアプリケーションを動かすってどういうこと?を消化。上記のページに概要がまとまっている。動画でも、6分程度だが、内容が分かりやすくまとめられているのでチェックすることをオススメ。

内容的に少しかぶるが、GAEのMVMsについての説明も目を通した。
Managed VMs - Google App Engine — Google Cloud Platform


要点:

これまでのGAEは、特定の言語、Python、Java、GoやPHPのランタイムをベースにしたWeb Serverと自分のコードが動くサンドボックス。環境は変えられず、全てはGoogleに管理されていた。

MVMsだと、カスタマイズしたGoogle Compute Engine(GCE)のVirtual Machines(VMs)上でGAEのような手軽さで、自分のコードを動かすことができるよ、GCEのIaaS的な柔軟性とGAEのPaaS的な管理の手軽さ、どちらもおいしいとこどりができるよ、というもの。

MVMsは、GAEのインスタンスを、GCE上で動かしてくれる。


MVMsが登場した背景やメリットについては、トップゲートさんの以下のページが参考になる。
GAE Managed VMs誕生までの歴史を振り返る | 株式会社トップゲート(Google Cloud Platform Partner / Google技術者集団)



2. Dartの開発環境の構築

Dart のツールやDart Editorをダウンロード。
https://www.dartlang.org/codelabs/darrrt/#set-up

解凍して、ローカルマシーンにdartディレクトリを配置したら、SDKのbin/までPATHをはる。

例えばMacでは、.bash_profileに以下を追加。
$ export PATH=$PATH:<installation directory>/dart/dart-sdk/bin



3. boot2dockerをインストール

Mac:Releases · boot2docker/osx-installer
上記のリンクからパッケージをダウンロードして、boot2dockerをインストールする。

boot2dockerは、VirtualBoxのためにパッケージされたLinux VMイメージ。このイメージの中には、boot2dockerのコマンドやDockerのデーモンが含まれている。



VirtualBoxは、DockerのVMデーモンをホストするもの。DockerコンテナはVirtualBox上で走る。なお、GAEのMVMsが対応しているのは、VirtualBox 4.3.10以上。

boot2dockerの最新は、v1.3.1(Docker v1.3.1、Linux v3.16.4)。VirtualBox v4.3.18-r96516を含んでいるので、インストールが完了すると、VirtualBoxもインストールされる。Macの場合、boot2dockerコマンドも、dockerコマンドも、/usr/local/binにインストールされる。



4. Docker設定

boot2dockerにはDocker v1.3.1が含まれているが、boot2dockerのVM内部で、Docker v1.3.0を要求するそうで、以下のコマンドを走らせて、その辺の設定を行う。

$ mkdir ~/.boot2docker
$ echo 'ISOURL = "https://github.com/boot2docker/boot2docker/releases/download/v1.3.0/boot2docker.iso"' > ~/.boot2docker/profile
$ boot2docker init
 



5. boot2docker起動

boot2docker起動。
$ boot2docker up

正常に起動したらセットアップ情報が表示される。GAEのMVMsでアプリケーション開発をするためには、必要ないので無視。
$ boot2docker up
Waiting for VM and Docker daemon to start...
.....o
Started.
Writing /Users/hogehoge/.boot2docker/certs/boot2docker-vm/ca.pem
Writing /Users/hogehoge/.boot2docker/certs/boot2docker-vm/cert.pem
Writing /Users/hogehoge/.boot2docker/certs/boot2docker-vm/key.pem

To connect the Docker client to the Docker daemon, please set:
    export DOCKER_TLS_VERIFY=1
    export DOCKER_HOST=tcp://192.168.59.103:2376
    export DOCKER_CERT_PATH=/Users/hogehoge/.boot2docker/certs/boot2docker-vm



6. Dockerイメージを取得

以下のコマンドを実行して、環境変数をセットアップ。
$ $(boot2docker shellinit)

次に、以下のコマンドを実行して、Googleが提供している幾つかのDockerイメージをダウンロードする。
$ docker pull google/docker-registry

イメージが無事に取得できたかどうか確認。google/dartリポジトリからのイメージも取得できているはず。
$ docker images
REPOSITORY    TAG    IMAGE ID    CREATED    VIRTUAL SIZE
google/dart  latest  cd7baf4008f8  2 weeks ago  243.6 MB
google/docker-registry  latest  5d4bb763edd7  3 weeks ago  428.8 MB

確認のため、Dart VMのバージョンナンバーを出力。VMが走っていないといけないので、gogole/dartをrunする。
$ docker run google/dart /usr/bin/dart --version
Dart VM version: 1.7.2 (Tue Oct 14 12:12:42 2014) on "linux_x64"


Dockerについて、最低限知っておくとよい知識:

Docker:コンテナ型の仮想化を実現するためのソフトウェア。VMwareなどのサーバの仮想化と違い、扱う単位がマシンではなくプロセスであることがポイント。サーバ管理で必要なあれこれは不要。

リポジトリ:コンテナを管理する単位。

コンテナ:プロセス空間やネットワーク等が外部から隔離された空間。コンテナの中でプロセスが動く。

イメージ:コンテナのテンプレート。イメージを元にして新しいコンテナを作る。


仮想化の単位がマシンイメージからコンテナイメージに移ってきた感じ。
Dockerの基本については、TECHSCOREさんのブログが参考になる。
» 隔離の技術Dockerの考え方と使い方の基本 TECHSCORE BLOG



7. Google Developer Consoleでプロジェクトを作成

Google Developers Console

プロジェクト名とプロジェクトIDを決定する。IDは後で変えられないので、よく考えて決める。

プロジェクト作成後、GAEやGCE周りの設定が必要なんじゃないかと試行錯誤したが、結局必要なことは単にプロジェクトを作るだけだった。



8. Google Cloud SDKをインストール

Mac:ターミナルで以下のコマンドを入力。インストールが終了したら、ターミナルを再起動。
$ curl https://sdk.cloud.google.com | bash

Google Cloud SDKを使うにはgmailアカウントを設定する認証作業が必要。
$ gcloud auth login

プロジェクトIDを設定。
$ gcloud config set project <my-project-id>

コンポーネントを最新にする。
$ gcloud components update app

設定を確認。
$ gcloud config list
[core]
account = xxx@gmail.com
disable_usage_reporting = False
project = <my-project-id>
user_output_enabled = True



9. Dart App Engineプロジェクトを作成

Dart App Engineプロジェクトとして、helloworldを作ってみる。ディレクトリ場所はお好みで。
$ mkdir helloworld

app.yamlファイルを作成。これはDart App Engineアプリケーションの設定ファイル。ディレクトリのトップ、ここではhelloworldディレクトリ直下、に配置する。
$ cd helloworld
$ vi app.yaml
app.yaml中身
version: helloworld
runtime: custom
vm: true
api_version: 1

続いて、Dockerfileファイルを作成。これも配置はディレクトリトップ。
$ vi Dockerfile
Dockerfile中身
FROM google/dart-runtime

更に、pubspec.yamlファイルを作成。pubspec.yamlは、Dartプログラムが依存するパッケージなどを定義する。これもこれも配置はディレクトリトップ。
$ vi pubspec.yaml
pubspec.yaml中身
name: helloworld
version: 0.1.0
author: Misaho Otsuka
dependencies:
  appengine: '>=0.2.1 < 0.3.0'



10. Dartプログラムを作成

サーバーサイドのDartプログラムを作成。配置はbin/下。ファイル名はserver.dart。Dart App Engineのプログラムは、bin/server.dartから実行を開始するので、このファイルは必須。
$ mkdir bin
server.dartの中身
import 'dart:io';
import 'package:appengine/appengine.dart';

main() {
  runAppEngine((HttpRequest request) {
    request.response..write('こんにちは、世界!')
                    ..close();
  });
}

runAppEngine()は、トップレベルメソッド。Dartのランタイムを走らせ、App Engineへ接続する。引数はコールバック関数。この例では、HttpRequestを使ってレスポンスに「こんにちは、世界!」と出力し、レスポンスをクローズしている。


Dartプログラムは、Dart Editorで編集してもいい。



最終的な階層構造。





11. pub getを実行

Dart App Engineのプロジェクトに必要なライブラリをインストールする。helloworldディレクトリ下で実行。
$ pub get
Resolving dependencies... 
Got dependencies!



12. Dartのランタイムイメージをダウンロード

これは1度だけ実行すれば良い。
$ docker pull google/dart-runtime

イメージがゲットできたことを一応、確認。
$ docker images
REPOSITORY    TAG    IMAGE ID    CREATED    VIRTUAL SIZE
google/dart-runtime  latest  e2ab3ccbce58  2 weeks ago  243.6 MB
google/dart  latest  cd7baf4008f8  2 weeks ago  243.6 MB
google/docker-registry  latest  5d4bb763edd7  3 weeks ago  428.8 MB



13. ローカルで実行確認

$ gcloud preview app run app.yaml
コンテナが起動した後は、ヘルスチェクが定期的に何度も呼び出される。
$ gcloud preview app run app.yaml
Module [default] found in file [/Users/hogehoge/git/mvm-sample/helloworld/app.yaml]
INFO: Looking for the Dockerfile in /Users/hogehoge/git/mvm-sample/helloworld
INFO: Using Dockerfile found in /Users/hogehoge/git/mvm-sample/helloworld
INFO: Skipping SDK update check.
INFO: Starting API server at: http://localhost:52671
INFO: Health checks starting for instance 0.
WARNING: Health check for instance 0 is not ready yet.
INFO: Building image test-mvmdart.default.helloworld...
INFO: Starting module "default" running at: http://localhost:8080
INFO: Starting admin server at: http://localhost:8000
INFO: Image test-mvmdart.default.helloworld built, id = 6a65a3d6cb36
INFO: Creating container...
INFO: Container 25f2f396d660bd8411e150e48ae05290922b3bebda07acaf8e90007a577aa045 created.
INFO: default: "GET /_ah/start HTTP/1.1" 200 2
INFO: default: "GET /_ah/health?IsLastSuccessful=no HTTP/1.1" 200 2
INFO: default: "GET /_ah/health?IsLastSuccessful=yes HTTP/1.1" 200 2
INFO: default: "GET /_ah/health?IsLastSuccessful=yes HTTP/1.1" 200 2

localhost:8080へアクセスすると、実行結果が確認できる。




「こんにちは、世界!」を「Hello, world!」へ変更して保存すると、




ブラウザでリロードすれば、変更が反映されていることが確認できる。




アプリケーションへの変更は監視されている。






ローカルサーバーを停止したい場合は、Control-C。gcloudコマンドでrunしたローカルサーバーをstopするコマンドは、無い。

ローカルで実行確認をすると、現在のプロジェクトの内容を元に、<my-project-id>.default.<app.yamlで定義したversion>という名前で、コンテナが作られる。





14. GAE上にデプロイ

$ gcloud preview app deploy app.yaml

注意:

デプロイし終わるまで結構時間がかかる!大体3分ほど。しかもヘルスチェクが5秒おきに30回程度実行される。でも待っていればヘルスチェクも終わって完全にデプロイ作業が完了する。デプロイが終わらないと、URLへアクセスしても、503になるだけなので辛抱。

$ gcloud preview app deploy app.yaml
Updating module [default] from file [/Users/hogehoge/git/mvm-sample/helloworld/app.yaml]
06:34 PM Host: appengine.google.com
{bucket: vm-containers.test-mvmdart.appspot.com, path: /containers}

06:36 PM Host: appengine.google.com
06:36 PM Application: test-mvmdart (was: None); version: helloworld
06:36 PM 
Starting update of app: test-mvmdart, version: helloworld
06:36 PM Getting current resource limits.
06:36 PM Scanning files on local disk.
06:36 PM Scanned 500 files.
06:36 PM Cloning 530 application files.
06:36 PM Starting deployment.
06:36 PM Checking if deployment succeeded.
06:36 PM Deployment successful.
06:36 PM Checking if updated app version is serving.
06:36 PM Will check again in 5 seconds.

... 中略

06:39 PM Will check again in 5 seconds.
06:39 PM Checking if updated app version is serving.
06:39 PM Enough VMs ready (2/2 ready).
06:39 PM Completed update of app: test-mvmdart, version: helloworld


デプロイが完了したら、

http://helloworld.<my_project_id>.appspot.com/

へアクセス。ローカルと同じ実行結果が確認できる。





Google Developer Consoleを確認すると、自動的に、GAEのインスタンス設定が行われ、GCEのVMインスタンスも設定されていることが確認できる。

GAEのダッシュボード。「Google管理」となっているのがMVMsの印。




GCEのVMインスタンスは自動的に2つ作成され、どちらもマシンタイプ、ゾーンは以下のようになっていた。





Docker お掃除コマンド Tips:

デプロイを行うと、localhost...をリポジトリとするイメージや<none>なイメージ、busyboxのイメージが増える。




停止したコンテナを消したり、不要なイメージを削除したりするお掃除コマンドは必須。

・停止したコンテナのリストを取得して削除する
docker rm $(docker ps -a -q)

・タグが<none>イメージを削除する
docker rmi $(docker images | grep "^<none>" | awk "{print $3}")

・デプロイする度に増えるlocalhost...をリポジトリとするイメージを削除する
docker rmi $(docker images | grep "localhost*" | awk "{print $3}")




結論: どれくらい手間?

Dart on App Engine、どれくらい管理やデプロイに手間がかかるか?というと、結局、GAEとあまり変わらない

一連の作業はプロセスが幾つもあって時間がかかる印象だが、一度環境を作ってしまえば、後は、サーバーサイドのDartプログラム書いて、runして、deployするだけ。仮想化も、サーバレベルでなく、コンテナレベルなので、プロジェクトに関係ないメンテ作業なども必要ない。


結論: どれくらいお金かかる?

手間はGAEと変わらないのだが、費用はGAEベースではなく、GCEベースなので、アクセスがなくてもインスタンスを削除しない限り費用が発生する。この点は注意。

今のところ、このhelloworld程度のプログラムをデプロイして、かかった費用は

3日間で$1.66

(内訳:Storage Pd Capacityに$0.03、Generic Small instance with 1 VCPU, no scratch diskに$1.63)

ということは、1日あたり$0.5程度。今のレートでは約50円。1ヶ月では約1,500円。悪くないんじゃない?という感じ。

2014年11月11日火曜日

Dart事始め

Dartの「GET STARTED」の一連のステップをやってみた。


「GET STARTED」で最終的にできあがるWebアプリの仕様

  • テキストフィールドへ名前が入力されると、右側のバッジ部分に「名前 the 称号」と海賊っぽく表示する

  • テキストフィールドが空の場合は、ボタンを押す度に「名前 the 称号」の組み合わせをランダムに表示する

  • テキストフィールドが空かどうかで、ボタンのラベルを変える
  • テキストフィールドへ入力すると、ボタンを無効化する
  • テキストフィールドへ入力する度に「名前 the 称号」の組み合わせをランダムに表示する
  • 「名前 the 称号」が変わる度に、名前と称号をローカルストレージへ保存する



Dartで書くときのポイントについて色々と気づき

  • importでライブラリ全体をインポート。特定のクラスをインポートしたい場合はshowで
  • final, staticなどはJavaと同じ意味で使用可能
  • privateキーワードは無い。_を付けた変数がprivate扱いになる
  • プログラムの実行主体はvoid main()
  • 入出力ストリームの監視はlisten()で
  • JavaScriptのPromisesと同じ扱いでFutureオブジェクトが使える。then()やcatchError()で、onSuccessとonErrorの扱いを切り分ける
  • カスケードオペレーター(..)で、あるオブジェクトの複数のプロパティへアクセスできる
  • window.localStorage['キー']でローカルストレージへアクセス
  • List<String>などGenericsが使用可能
  • getterは、戻り値 get プロパティと定義する。例えば、getPirateName()はString get pirateName
  • => はreturn expr;の省略形として使える
  • $は文字列内で変数を明示する時に使える(print('Error occurred: $error');)
  • コンストラクタも定義できるPirateName(){}だけでなく、PirateName.fromJSON(){}といった定義も可能
  • オプションの引数はcurly brackets({})で指定可能
  • 型変換はasで(e.target as InputElement)
  • querySelector()などでidを渡してDOM操作することになる。値のバインディングは Angularなどで別途自分でやる



「GET STARTED」で最終的にできあがるWebアプリのリソース

  • piratebadge.css
  • piratebadge.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Pirate badge</title>
    <meta name="viewport"
          content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="piratebadge.css">
  </head>
  <body>
    <h1>Pirate badge</h1>
    
    <div class="widgets">
      <div>
        <input type="text" id="inputName" maxlength="15" disabled>
      </div>
      <div>
        <button id="generateButton" disabled>
          Aye! Gimme a name!
        </button>
      </div>
    </div>
    <div class="badge">
      <div class="greeting">
        Arrr! Me name is
      </div>
      <div class="name">
        <span id="badgeName"> </span>
      </div>
    </div>
    <script type="application/dart" src="piratebadge.dart"></script>
    <script src="packages/browser/dart.js"></script>
  </body>
</html>

  • piratenames.json : 名前と称号のリストを含んだJSONファイル
{ "names": [ "Anne", "Bette", "Cate", "Dawn",
        "Elise", "Faye", "Ginger", "Harriot",
        "Izzy", "Jane", "Kaye", "Liz",
        "Maria", "Nell", "Olive", "Pat",
        "Queenie", "Rae", "Sal", "Tam",
        "Uma", "Violet", "Wilma", "Xana",
        "Yvonne", "Zelda",
        "Abe", "Billy", "Caleb", "Davie",
        "Eb", "Frank", "Gabe", "House",
        "Icarus", "Jack", "Kurt", "Larry",
        "Mike", "Nolan", "Oliver", "Pat",
        "Quib", "Roy", "Sal", "Tom",
        "Ube", "Val", "Walt", "Xavier",
        "Yvan", "Zeb"],
  "appellations": [ "Awesome", "Captain",
        "Even", "Fighter", "Great", "Hearty",
        "Jackal", "King", "Lord",
        "Mighty", "Noble", "Old", "Powerful",
        "Quick", "Red", "Stalwart", "Tank",
        "Ultimate", "Vicious", "Wily", "aXe", "Young",
        "Brave", "Eager",
        "Kind", "Sandy",
        "Xeric", "Yellow", "Zesty"]}

  • piratebadge.dart : Dartプログラム
// Import Dart Library.
import 'dart:html';
// 'show' keyword, which imports only the specified classes.
import 'dart:math' show Random;
import 'dart:convert' show JSON;
import 'dart:async' show Future;

// Declare button element as a global variable.
ButtonElement genButton;

// Declare span element as a global variable.
SpanElement badgeNameElement;

// key-value pairs string save to local storage.
final String TREASURE_KEY = 'pirateName';

// App starts here.
void main() {
  // Stash the input element in a local variable.
  InputElement inputField = querySelector('#inputName');
  // Listen input streams.
  inputField.onInput.listen(updateBadge);
  genButton = querySelector('#generateButton');
  genButton.onClick.listen(generateBadge);
  badgeNameElement = querySelector('#badgeName');

  // Call the function, which returns a Future(silimar to JavaScript Promises).
  // Using underscore (_) as a parameter name indicates that the parameter is ignored.
  PirateName.readyThePirates().then((_) {
    // on success
    inputField.disabled = false; // enable
    genButton.disabled = false; // enable
    // Retrieve the name from local storage.
    setBadgeName(getBadgeNameFromStorage());
  }).catchError((error) {
    // '$' indicates that this is a variable, not a string.
    print('Error initializing pirate names: $error');
    badgeNameElement.text = 'Arrrr! No names.';
  });
}

void updateBadge(Event e) {
  // use 'as' to type casting
  String inputName = (e.target as InputElement).value;
  setBadgeName(new PirateName(firstName: inputName));
  if (inputName.trim().isEmpty) {
    // The cascade operator (..) allows you to perform multiple
    // operations on the members of a single object.
    genButton
        ..disabled = false
        ..text = 'Aye! Gimme a name!';
  } else {
    genButton
        ..disabled = true
        ..text = 'Arrr! Write yer name!';
  }
}

void setBadgeName(PirateName newName) {
  if (newName == null) {
    return;
  }
  querySelector('#badgeName').text = newName.pirateName;
  // Save the name to local storage.
  window.localStorage[TREASURE_KEY] = newName.jsonString;
}

void generateBadge(Event e) {
  setBadgeName(new PirateName());
}

// The function retrieves the name from local storage and
// creates a PirateName object from it.
PirateName getBadgeNameFromStorage() {
  String storedName = window.localStorage[TREASURE_KEY];
  if (storedName != null) {
    return new PirateName.fromJSON(storedName);
  } else {
    return null;
  }
}

// declare class
class PirateName {
  // final variables cannot change.
  static final Random indexGen = new Random();
  // Declare generic type—List.
  static List<String> names = [];
  static List<String> appellations = [];

  // Private variables start with underscore (_).
  // Dart has no private keyword.
  String _firstName;
  String _appellation;

  // Provide a constructor for the class.
  // curly brackets{} indicates optional parameters.
  PirateName({String firstName, String appellation}) {
    if (firstName == null) {
      _firstName = names[indexGen.nextInt(names.length)];
    } else {
      _firstName = firstName;
    }
    if (appellation == null) {
      _appellation = appellations[indexGen.nextInt(appellations.length)];
    } else {
      _appellation = appellation;
    }
  }

  // The constructor creates a new PirateName instance
  // from a JSON-encoded string.
  PirateName.fromJSON(String jsonString) {
    Map storedName = JSON.decode(jsonString);
    _firstName = storedName['f'];
    _appellation = storedName['a'];
  }

  // Declare a class level method.
  static Future readyThePirates() {
    // If you spell miss the json file name,
    // executes PirateName.readyThePirates().catchError() in main().
    var path = 'piratenames.json';
    // getString() returns Future is used as arguments
    // to _parsePirateNamesFromJSON().
    // then() is a callback function is called when the Future completes successfully.
    return HttpRequest.getString(path).then(_parsePirateNamesFromJSON);
  }

  // Declare a instance and private method.
  static _parsePirateNamesFromJSON(String jsonString) {
    Map pirateNames = JSON.decode(jsonString);
    names = pirateNames['names'];
    appellations = pirateNames['appellations'];
  }

  // Provide a getter for the private variables
  // The fat arrow ( => expr; ) syntax is a shorthand for { return expr; }.
  String get pirateName => _firstName.isEmpty ? '' : '$_firstName the $_appellation';

  // Add a getter to the PirateName class that encodes a pirate name in a JSON string.
  String get jsonString => JSON.encode({
    "f": _firstName,
    "a": _appellation
  });
}


実行は以下から確認できる。

Step6 Run the app.