ぴよぴよエンジニアの日記

クラウドベンダーに勤める見習いSEの日記です。発言は私自身の見解であり、必ずしも所属組織の立場、戦略、意見を代表するものではありません。

Bluemix 上に Redmine の Docker イメージをデプロイしてみた

今回は今までのエントリーとは少し違った変わり種です.


最近始めたチームプロジェクトを TiDD 風に進めたく、Redmine を運用することになりました.

Redmine をどこに構築するかいくつか選択肢がありましたが、せっかくなので Bluemix 上に構築することにしました.

今回は Bluemix 上に Redmine の Docker イメージをデプロイし、カスタムドメインhttps 化するまでの流れをやってみます.





前提

今回は以下の環境を使用しています.

  • macOS Sierra 10.12.5
  • Bluemix CLI version 0.5.4+ae22935-2017-05-18T06:24:28+00:00
  • Docker version 17.03.1-ce, build c6d412e



Docker のインストー

最初は Docker のインストールです.
(既に Docker をインストール済みの方は読み飛ばしていただいて構いません)


f:id:Santea:20170617221553j:plain

Install Docker for Mac | Docker Documentation

上記リンクから "Get Docker for Mac [Stable]" を選択し、Docker.dmg をダウンロードし、インストールします.

$ docker -v
Docker version 17.03.1-ce, build c6d412e

上記コマンドを実行し、バージョン情報が表示されていればインストール成功です.



Bluemix CLI とBluemix Container Service plug-in のインストー

IBM Bluemix Container Service

Docker イメージのデプロイ先として、IBM Bluemix Container を用います.


Bluemix Container を利用するためには Bluemix CLI が必要なのでまずはこれをインストールしましょう.

Bluemix CLI Home

上記リンクにアクセスし、CLI をダウンロードします.

f:id:Santea:20170617220734p:plain

Bluemix_CLI_0.5.4.pkg(Macの場合)を起動すると上記画面が表示されますので、手順に従ってインストールします.

$ bx -v
bx version 0.5.4+ae22935-2017-05-18T06:24:28+00:00

ターミナルで上記コマンドを実行してバージョン情報が表示されていればインストール成功です.


続いて Bluemix Container Service Container Service plug-in のインストールを行います.
コマンドラインで以下に続くコマンドを実行してください.

$bx login -a https://api.ng.bluemix.net

Bluemix CLI にログインします.

$bx plugin install IBM-Containers -r Bluemix
$bx plugin list
インストール済みプラグインをリストしています...

プラグイン名     バージョン   
IBM-Containers   1.0.0   

インストール済みプラグインのリストに IBM-Containers が表示されていればインストール成功です.



Redmine の Docker イメージを Bluemix にデプロイする

まずは Docker を起動しましょう.

f:id:Santea:20170617223309j:plain

Launcher で Docker を選択します.


f:id:Santea:20170617223437p:plain:w250

ステータスバーの Docker 項目が Docker is running となっていれば起動成功です.


次に Bluemix にログインします.

$ bx login
API エンドポイント: https://api.ng.bluemix.net

Email> example@mail.com

Password> 
認証中です...
OK

アカウントを選択します (または Enter キーを押してスキップします):
1. Daiki Kawanuma's Account (XXXXXXXXXXXXXXX)
Enter a number> 1
ターゲットのアカウント Daiki Kawanuma's Account (XXXXXXXXXXXXXXX)

ターゲットの組織 BlumixDev

ターゲットのスペース BluemixDev


                         
API エンドポイント:   https://api.ng.bluemix.net (API バージョン: 2.54.0)   
地域:                 us-south   
ユーザー:             example@mail.com   
アカウント:           Daiki Kawanuma's Account (XXXXXXXXXXXXXXX)   
組織:                 BlumixDev   
スペース:             BluemixDev  

コマンドラインで bx login コマンドを実行してください.
メールアドレスとパスワードを要求されます.
正常にログインできれば上記のような結果が返ってくるはずです.


Bluemix Container を初期化します.

$ bx ic init
古い構成ファイルを削除中です...
open /Users/DaikiKawanuma/.cf/config.json: no such file or directory
IBM Containers のクライアント証明書を生成中です...
クライアント証明書を /Users/DaikiKawanuma/.ice/certs/ に格納中です...

クライアント証明書を /Users/DaikiKawanuma/.ice/certs/containers-api.ng.bluemix.net/XXXXXXXXXX に格納中です...

OK
クライアント証明書が取得されました。

ローカルの Docker 構成を確認中です...
OK

ホスト名 registry.ng.bluemix.net のレジストリーで認証中
OK
コンテナーは IBM Containers レジストリーで認証されました。
プライベート Bluemix リポジトリーは URL registry.ng.bluemix.net/XXXXXXXXXX です。

IBM Containers と共に Docker CLI を使用するには、次の 2 つの方法から選択することができます。


オプション 1: このオプションでは、ローカル Docker ホストを管理するために Docker CLI を直接使用しながら、IBM Containers 上のコンテナーを管理するための「bluemix ic」を使用できます。
	この Cloud Foundry IBM Containers プラグインは、ローカル Docker 環境に影響を与えることなく使用してください。


	使用法の例:
	bluemix ic ps
	bluemix ic images

オプション 2: Docker CLI を直接使用します。 このシェルでは、これらの変数を設定することにより、ローカル Docker 環境をオーバーライドして IBM Containers に接続します。 以下のコマンドをコピーして貼り付けてください。
	注: このオプションでサポートされるのは、後ろに (Docker) が付く Docker コマンドのみです。 
 	export DOCKER_HOST=tcp://containers-api.ng.bluemix.net:8443
 	export DOCKER_CERT_PATH=/Users/DaikiKawanuma/.ice/certs/containers-api.ng.bluemix.net/XXXXXXXXXX
 	export DOCKER_TLS_VERIFY=1

	使用法の例:
	docker ps
	docker images

コマンドラインで bx ic init コマンドを実行してください.

プライベート Bluemix リポジトリーは URL registry.ng.bluemix.net/XXXXXXXXXX です。

正常に初期化できた場合、上記のようにリポジトリーの URL が発行されますので、こちらを控えておきましょう.


Redmine の Docker イメージを pull します.

$ docker pull redmine
Using default tag: latest
latest: Pulling from library/redmine
10a267c67f42: Pull complete 
0aaa89427703: Pull complete 
4e4351445696: Pull complete 
72c399ee88ad: Pull complete 
cd4fc9895ed7: Pull complete 
00facae99acf: Pull complete 
39fe42e23efc: Pull complete 
adcb64833163: Pull complete 
605738c68599: Pull complete 
eb8b57703d24: Pull complete 
b0d2fec5a473: Pull complete 
7bd6e321e449: Pull complete 
509199c744bc: Pull complete 
72bc2865edcf: Pull complete 
89bcf430a558: Pull complete 
Digest: sha256:6161e523b5a7d66eaa27c5e16eb62b327ea846f36e5b1f3d955bb333d631cec2
Status: Downloaded newer image for redmine:latest

コマンドラインで docker pull redmine コマンドを実行します.
全ての項目が Pull complete になれば正常に pull できています.


Docker イメージにタグ付けを行います.

docker tag redmine registry.ng.bluemix.net/XXXX/my_redmine

コマンドラインで上記のコマンドを実行します.
"XXXX" の部分には先ほど控えておいたレジストリの URL を指定します.
"my_redmine" の部分は Bluemix 上の Docker イメージ名になりますのでお好きな名前を付けてください.


Bluemix 上に Redmine の Docker イメージをデプロイします.

$ docker push registry.ng.bluemix.net/XXXX/my_redmine
The push refers to a repository [registry.ng.bluemix.net/XXXX/my_redmine]
bbaad5f84f54: Pushed
8a2f78a107d8: Pushed 
77e8840346cc: Pushed
dc33c98e0afc: Pushed
580223b85643: Pushed 
912c9c81592d: Pushed 
daf18abaed19: Pushed 
4166db05041d: Pushed
fb1319c5dbe9: Pushed
f4d4ef112a81: Pushed 
41636605749a: Pushed 
c682b1980b3e: Pushed
8157cc9b225e: Pushed
2a9adabb6623: Pushed
8d4d1ab5ff74: Pushed 
latest: digest: sha256:XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX size: 3466

コマンドラインで docker push コマンドを実行します.
push 以下の引数には先程タグ付けした値と同様のものを指定します.

なお、デプロイ中は Web 上で Bluemix にログインしないようにしましょう.
途中でログインした場合、認証に失敗し最終的にデプロイが成功しません.

間違ってログインしてしまった場合でも、再度デプロイし直せば正常にデプロイできます.



カスタムドメインの指定と証明書の登録

いよいよ Bluemix 上で Redmine を起動するというところですが、その前にカスタムドメインと証明書の登録を行いましょう.

まずは証明書を作ります.
今回は Redmine の運用が目的なので"オレオレ証明書"で良いことにします.

IBM Bluemix を独自ドメインで使う(SSL編) : まだプログラマーですが何か?
証明書については上記エントリーで解説されていますので、詳しく知りたい方はご覧ください.


証明書を作成します.

$ openssl req -new -key server.key -out server.csr

You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:JP
State or Province Name (full name) [Some-State]:Tokyo
Locality Name (eg, city) []:Hakozaki
Organization Name (eg, company) [Internet Widgits Pty Ltd]:Project-Respite
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []:*.project-respite.com
Email Address []:

***残りは全て Enter***

Please enter the following 'extra' attributes
to be sent with your certificate request
A challenge password []:
An optional company name []:

コマンドラインで openssl req -new -key server.key -out server.csr コマンドを実行します.
いくつか回答項目が表示されますので、Email まで回答し、残りは全て Enter します.

openssl x509 -days 3653 -in server.csr -out server.crt -req -signkey server.key

Signature ok
subject=/C=JP/ST=Saitama/L=Urawa/O=Project-Respite/CN=*.project-respite.com
Getting Private key

更に上記のコマンドを実行すると、server.key, server.csr, server.crt が生成されているはずです.

ここまでで鍵生成は完了です.


次にカスタムドメインの登録を行います.

f:id:Santea:20170617231922j:plain:w450

Bluemix にログインし、ページ右上のアカウントの項目で、"組織の管理"を選択します.


f:id:Santea:20170617232402j:plain

移動先のページにて、"組織の編集"を選択します.


f:id:Santea:20170617232749j:plain

タブ上の"ドメイン"の中の、"ドメインの追加"を選択します.
ドメイン名を追加したら、下の保存を選択してください.


f:id:Santea:20170617234528j:plain

保存するとSSL証明書の欄のアイコンが変わると思いますので、こちらを選択します.


f:id:Santea:20170617235340j:plain:w550

ポップアップ上で先程生成した Server.crt と Server.key を参照します.


最後にドメインサービスに Bluemix の CNAME を登録します.

f:id:Santea:20170618000517p:plain

こちらはお名前.comの例ですが、上記のように CNAME に "secure.us-south.bluemix.net" を指定してください.


これで下準備は完了です.



Redmine の起動

いよいよ Bluemix 上で Redmine を起動します.

f:id:Santea:20170617231201j:plain

トップ画面で上端タブの右にある"カタログ"を選択します.

画面左端のすべてのカテゴリーから"アプリ"の中の"コンテナ"を選択します.

コンテナの項目に先程デプロイした "my_redmine" を選択します.


f:id:Santea:20170617235959j:plain

コンテナ方式は"スケーラブル"を選択します(単一だとカスタムドメインを指定できないため).
コンテナ名、サイズ、インスタンスはお好きなものを設定してください(今回は最小構成にしています).


f:id:Santea:20170618001246j:plain

ドメインにカスタムドメインを指定し、作成を選択すれば Redmine が起動されます.


f:id:Santea:20170618001609p:plain

起動画面はこんな感じです.
メモリ使用量 365GBHour/Month までは無料枠で使えます.



まとめ

今回は Bluemix 上に Redmine の Docker イメージをデプロイし、起動してみました.

慣れれば10分!とまではいかないまでも、30分程度あればすぐに Redmine を構築できると思います.


少規模であれば無料枠で運用できると思いますので、ぜひお試しください.


以上です.


参考

フリップチャートを認識するアプリを作ってみた

前回、Xamarin で Watson Visual Recognition の Recognize Text を使うエントリーを書きました.

santea.hateblo.jp


今回はより具体的な使用用途として、フリップチャートに貼ったポストイットを認識するスマートフォンアプリを作ってみます.
ポストイットに書いてある文字と、ポストイットの色をセットで認識します.

アプリの実装は Xamarin.Forms で行います.





Xamarin.Forms の実装

まずは写真を撮影するページの実装です.

写真の撮影には Media Plugin for Xamarin and Windows を用いました.

github.com

また画像の文字領域を切り取るのに @muax__x さんの Image Edit Plugin for Xamarin を使わせていただきました.

github.com


PhotoPage.xaml です.
Button と Image を配置しています.

<?xml version="1.0" encoding="utf-8"?>

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:FlipChartRecognitionApp"
             x:Class="FlipChartRecognitionApp.PhotoPage"
             Title="Photo">

    <StackLayout>
        <Grid HorizontalOptions="FillAndExpand"
              VerticalOptions="FillAndExpand">

            <Label x:Name="label"
                   Text="Please take a picture of the flip chart."
                   VerticalOptions="Center"
                   HorizontalOptions="Center"/>

            <ActivityIndicator x:Name="indicator"
                               IsRunning="True"
                               IsVisible="False"
                               VerticalOptions="Center"
                               HorizontalOptions="Center"/>

            <Image x:Name="image"
                   HorizontalOptions="FillAndExpand"
                   VerticalOptions="FillAndExpand"
                   Margin="5,12,5,0" />
        </Grid>
        <Button BackgroundColor="#2196F3"
                TextColor="White"
                Text="Take Photo"
                Clicked="Handle_Clicked" />
    </StackLayout>

</ContentPage>


PhotoPage.xmal.cs です.

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using Newtonsoft.Json;
using Plugin.ImageEdit;
using Plugin.Media;
using Plugin.Media.Abstractions;
using Xamarin.Forms;

namespace FlipChartRecognitionApp
{
    public partial class PhotoPage : ContentPage
    {
        private const string Url =
                "https://gateway-a.watsonplatform.net/visual-recognition/api/v3/recognize_text?api_key={API_KEY}&version=2016-05-20";

        private Texts _texts;
        private List<Word> _words;

        public PhotoPage()
        {
            InitializeComponent();
        }

        public async void Handle_Clicked(object sender, EventArgs e)
        {
            label.IsVisible = false;
            image.Source = null;
            indicator.IsVisible = true;
            _words = new List<Word>();

            await CrossMedia.Current.Initialize();

            if (!CrossMedia.Current.IsCameraAvailable || !CrossMedia.Current.IsTakePhotoSupported)
            {
                await DisplayAlert("No Camera", "No camera available.", "OK");
                return;
            }

            var file = await CrossMedia.Current.TakePhotoAsync(new StoreCameraMediaOptions
            {
                Directory = "Sample",
                Name = $"{DateTime.Now:yyMMdd-hhmmss}.jpg",
                PhotoSize = PhotoSize.Small
            });

            if (file == null)
                return;

            var imageByteArray = ReadFully(file.GetStream());

            RecognizeTexts(imageByteArray);
            AnalyzeColor(imageByteArray);

            image.Source = ImageSource.FromStream(() =>
            {
                var stream = file.GetStream();
                file.Dispose();
                return stream;
            });

            indicator.IsVisible = false;
        }

        private void RecognizeTexts(byte[] imageByteArray)
        {
            var content = new MultipartFormDataContent();

            var imageContent = new ByteArrayContent(imageByteArray);
            imageContent.Headers.ContentType = MediaTypeHeaderValue.Parse("image/jpeg");
            content.Add(imageContent);

            var httpClient = new HttpClient();
            var response = httpClient.PostAsync(Url, content).Result;

            _texts = JsonConvert.DeserializeObject<Texts>(response.Content.ReadAsStringAsync().Result);
        }

        private async void AnalyzeColor(byte[] imageByteArray)
        {
            if (_texts.images.Length == 0 || _texts.images[0].words.Length == 0)
            {
                DisplayAlert("Result ", "Could not recognize character", "OK");
                return;
            }

            foreach (var word in _texts.images[0].words)
            {
                var editableImage = await CrossImageEdit.Current.CreateImageAsync(imageByteArray);

                var pixels = editableImage.Crop(word.location.left,
                        word.location.top,
                        word.location.width,
                        word.location.height)
                    .ToArgbPixels()
                    .OrderBy(x => x % 0x1000000 / 0x10000 + x % 0x10000 / 0x100 + x % 0x100)
                    .ToArray();

                var instace = new Word
                {
                    Text = word.word,
                    Color = "#" + Convert.ToString(pixels.Last(), 16)
                };
                _words.Add(instace);
            }

            await Navigation.PushAsync(new PostItsPage(_words));
        }

        private static byte[] ReadFully(Stream input)
        {
            using (var ms = new MemoryStream())
            {
                input.CopyTo(ms);
                return ms.ToArray();
            }
        }
    }
}

HttpClient の部分は前回のエントリーと一緒なので省略します.
以下、AnalyzeColor を抜粋して説明します.

var pixels = editableImage.Crop(word.location.left,
        word.location.top,
        word.location.width,
        word.location.height)
.ToArgbPixels()
.OrderBy(x => x % 0x1000000 / 0x10000 + x % 0x10000 / 0x100 + x % 0x100)
.ToArray();

Image Edit Plugin for Xamarin を用い、編集可能な Image を取得します.

Recognize Text の JSON からは文字領域の座標を取得できるので、それの left, top, width, height を指定し Crop します.
このとき Crop の返り値は Crop 処理された editableImage 自身なので注意が必要です(Crop で新たな Image インスタンスが生成されているわけではありません).

Crop された Image を ARGB 情報の int配列に変え、ソートしています.
(黒とポストイットの色を判別できればいいだけなので、ソートのアルゴリズムはテキトーです.)

ここまでが写真撮影ページの実装です.


次に認識した文字を一覧で表示するページの実装です.

PostItsPage.xaml です.

<?xml version="1.0" encoding="UTF-8"?>

<ContentPage xmlns="http://xamarin.com/schemas/2014/forms"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             x:Class="FlipChartRecognitionApp.PostItsPage">

    <ListView x:Name="listView"
              Margin="0,10,0,10"
              VerticalOptions="FillAndExpand"
              HorizontalOptions="FillAndExpand">
        <ListView.ItemTemplate>
            <DataTemplate>
                <ViewCell>
                    <StackLayout Orientation="Horizontal"
                                 BackgroundColor="{Binding Color}">
                        <Label Text="{Binding Text}"
                               TextColor="White"
                               FontSize="32"
                               Margin="10,0,0,0"/>
                    </StackLayout>
                    </ViewCell>
            </DataTemplate>
        </ListView.ItemTemplate>
    </ListView>

</ContentPage>

カスタムの ViewCell に StackLayout を配置し、BackgroundColor をバインドしています.

StackLayout の中には Label を配置し、Text をバインドしています.


PostItsPage.xaml.cs です.

using System;
using System.Collections.Generic;

using Xamarin.Forms;

namespace FlipChartRecognitionApp
{
	public partial class PostItsPage : ContentPage
	{
		public List<Word> Words { get; set; }

		public PostItsPage(List<Word> words)
		{
		    InitializeComponent();

		    Words = words;

		    listView.ItemsSource = Words;
		}
	}
}

コードビハインドは説明不要かと思います.


最後に画像の文字領域の色をポストイットの色にマッピングする Word モデルの実装です.

using System;

namespace FlipChartRecognitionApp
{
    public class Word
    {
        private const string Yellow = "#fded7d";
        private const string Red = "#fbb6cd";
        private const string Blue = "#96cdea";
        private const string Green = "#b9fcba";

        public string Text { get; set; }

        private string _color;

        public string Color
        {
            get => _color;
            set => _color = GetSimilarColor(value);
        }

        private static string GetSimilarColor(string targetHexColor)
        {
            var targetColor = Xamarin.Forms.Color.FromHex(targetHexColor);

            if (targetColor.G > targetColor.R && targetColor.G > targetColor.B)
                return Green;

            var distance = 1.8;
            var returnColor = "";

            foreach (var hexColor in new[] {Yellow, Red, Blue, Green})
            {
                var color = Xamarin.Forms.Color.FromHex(hexColor);

                var d = Math.Sqrt(Math.Pow(targetColor.R - color.R, 2)
                                  + Math.Pow(targetColor.G - color.G, 2)
                                  + Math.Pow(targetColor.B - color.B, 2));

                if (d >= distance) continue;

                distance = d;
                returnColor = hexColor;
            }

            return returnColor;
        }
    }
}

基本的には文字領域の色と、ポストイットの色のRGB3次元ユークリッド距離が最も小さい色を選びます.

ただし、緑は黄色との判別が上手くいかなかったので、RGBの中でGが最も大きい場合は問答無用で緑を選択します.


以上が Xamarin.Forms の実装でした.



実装結果

f:id:Santea:20170504211920j:plain

9つのポストイットに対し、6つしか認識されませんでした.
何回か試しましたが、毎回の認識具合も多少振れがありました.

Recognize Text はβ版ですが、GA時にはもう少し精度が上がっていてほしいですね.



まとめ

今回は Recognize Text を使ってフリップチャートのポストイットを認識するアプリを作ってみました.

Recognize Text は文字を認識するだけでなく、認識した文字の座標値も含めて返してくれるので色々応用の幅が広いと思います.
今回は色を組み合わせてみましたが、他にも色々使い道がありそうです.


以上です.


PS.
Xamarin の高度な抽象化はハマると本当に簡単に書けてよいですね!
今回ロジック部分は200行も書いてないです.本当に助かりました.