EatSmartシステム部ブログ

ウェブサイトの開発や運営に関する情報です。

正規表現について

イートスマートの新人エンジニアが、正規表現について学んだことを振り返ってみます。

正規表現について

文字列のデータの中から、特定のパターン(文字の組み合わせ)にマッチするかを調べ るために用いられる表現です。文字列の完全一致だけでなく、より複雑な条件を指定し 例えば先頭の文字がaで始まり、末尾がbで終わる文字列の一致などをみることができます。

実装の内容

もぐナビの食品に関する情報について、スマホサイト側で単位当たりのカロリー表示を 出すため変更が必要となりました。 その際に、補足説明を表すカラムの内容を出せば一発解決ですが、全ての内容を出力するには問題があったため正規表現を使って対応しました。

f:id:eatsmart:20190524132543p:plain

正規表現に関する実装例

public String getCalorieUnit {
  //判定する文字列を変数に定義する
  String d = description;
 //判定する文字列のパターンを指定する
 String regex = "栄養成分.*(?<=あたり|当たり|当り|g|)"; ※
 //正規表現のパターンをコンパイルする
  Pattern p = Pattern.compile(regex);
  if(CheckUtil.isNull(d)) {
    return "";
        }
  //正規表現を利用したい文字列を渡してオブジェクトを作成
  Matcher m = p.matcher(d);
  //カロリーの値がnullではなく、かつ指定したパターンに一致した場合trueが返る
    if (calorie != null && m.find()) {
 //group()メソッドでマッチした部分の文字列が取得できる
        return m.group();
    }
    return "";
}

使用した正規表現※について

今回は、規則性として「栄養成分~」から始まり、あたり(当たり、当り)、 mg,gなどで終わるパターンを想定しています。 (「g」を入れているのは、例えば「栄養成分100mlあたり※カフェイン約10mg」というデータが存在したため。)

まず、この表現の中で用いられている「.」任意の1文字を指します。 「*」は直前の文字を0回以上繰り返すという意味になります。 ここまでで、「〇〇栄養成分〇〇」という文字列に合致したものがマッチします。 「?<=~」で文字列が~に該当した場合にだけ一致するという条件をつけます。 ~の場合は、複数のワードが入る可能性があるため、前後のどちらかに一致するかを 表す「|」で該当のワードを複数列挙しています。 以上から、「〇〇栄養成分〇〇あたり」に該当するようなデータがヒットするようになります。

テストコードの一例

public void test_getCalorieUnit() {
        BigDecimal bd = BigDecimal.valueOf(250);
        FoodBean food = new FoodBean();

        food.setCalorie(bd);
        food.setDescription("栄養成分1食320gあたり※関東限定");
     //販売地域に関する情報が不要なため、それらを除外した形で出力できているかをテスト
        assertThat(food.getCalorieUnit(), is("栄養成分1食320gあたり"));

        food.setDescription("***栄養成分100ml当たり");
        //栄養成分という文字列が文の途中で現れる場合を想定
        assertThat(food.getCalorieUnit(), is("栄養成分100ml当たり"));

        food.setCalorie(null);
        //カロリー情報がない場合には空で返却されているか
        assertThat(food.getCalorieUnit(), is(""));
    }

正規表現ワイルドカードについて(参考)

ワイルドカードは、正規表現と同様に文字列のパターンを表すことができます。 例えばワイルドカードでは「 * 」は0文字以上の文字列,「 ? 」は任意の1文字を指します。 これが正規表現の場合ですと、それぞれ「 .* 」、「 . 」という書き方になります。 ワイルドカードが使われる場面としては、例えばコマンドラインでファイル検索をするときに bashで利用されるものになります。bashなどのシェルでは、正規表現を解釈するようには なっていないため、ワイルドカードで表現することになります。

LINE@のサービス統合について

既にご存知の方も多いかと思いますが、LINEは2019年4月18日に法人向けアカウントサービス「LINE@」を「LINE公式アカウント」に統合しました。今回はその辺りについてまとめてみたいと思います。

統合の概要

2019年4月18日で「LINE@」は「LINE公式アカウント」に統合され、2019年4月18日以降LINE@アカウントを新規に作成出来なくなりました。
また、LINE DevelopersコンソールでMessaging APIのチャネルを作成すると、LINE@アカウントではなくLINE公式アカウントが作成されます。
尚、LINE@のPC管理画面「LINE@マネージャー」も、「LINE Official Account Manager」にリニューアルされました。

blog-at.line.me

統合による変更点

LINE Developer Trialプラン廃止

開発者向けコンソールLINE Developerで、Trial用に存在していたLINE Developer Trialプランは友だち数が最大で50人という制限がありましたが、Trialプラン自体が廃止され新プランでは無制限となりました。
また、LINE@のときは有料のプランでなければ利用できなかったMessaging APIを無料で利用できる様になりました。
尚、LINE Developer Trialでは有料プランへの切り替えが行えませんでしたが、LINE公式アカウントは変更可能となりました。
engineering.linecorp.com

料金プラン

課金モデルが配信通数に応じた従量課金型へと変更されました。
また、フリープランでもMessaging APIにて、毎月1,000通まで無料でプッシュメッセージを送信できる様になりました。

www.linebiz.com

LINE@からの移行

「LINE@」を利用している方は、2019年4月18日~5月(中)下旬にかけて移行の案内が開始されます。 また、5月(中)下旬から、各利用者にて移行が可能となります。
最終的に、2019年8月19日からLINE側で強制移行されます。
尚、LINE@アカウントを有料で利用されていた方は、移行時に無料or有料プランを選択できるそうです。

www.linebiz.com

検証

今回は、フリープランで新規にLINE公式アカウントの開設~アプリとLINEのログイン連携までを検証してみましたが、概ね問題なく動作することが出来ました。簡単ですが、手順を記載します

1)LINE Developersのアカウント開設

LINE Developers

2)LINEログイン用チャンネル作成

LINEログインを利用するには

3)アプリにLINEログインの組み込み

ウェブアプリにLINEログインを組み込む

アクセストークンを管理する

ユーザープロフィールを取得する

最後に

今回、LINE公式アカウントを利用してみた印象としては、料金プランや各種サービスなどがシンプルになり、且つLINE DeveloperについてはTrialプランが廃止されたことで、よりフレキシブルに利用できるようになったのではと思いました。

Javaのバッチ処理をネイティブモジュールにする

昨年インフラの刷新ではフロントエンドのサーバ群をDockerコンテナ化したのと合わせて、Javaを利用するバッチ処理もDockerコンテナ化しました。 これにより、Dockerコンテナの実行さえできればどのサーバでもバッチが実行できるようになりました。

以前に比べればバッチを実行するサーバの構築が簡単になりましたが、もっと簡単にJavaバッチ処理を実行出来ないか調べてみました。 その中で、micronautというJava言語向けのフルスタックフレームワークとGraalVMを利用することで、Javaの処理をネイティブモジュールに出来ることがわかりました。 今回はコレに挑戦してみたいと思います。

micronautの準備

SDKをダウンロードして実行してみます。まずは以下からダウンロードします。

https://micronaut.io/download.html

次に、実行するためパスを通しました。今回はPowerShellを利用しました。

Set-Item Env:Path "$Env:Path;C:\Users\xxxx\libs\micronaut-1.1.0\bin"

パージョンを確認してみます

mn -V
| Micronaut Version: 1.1.0
| JVM Version: 1.8.0_162

CLIアプリケーションを作成

以下を参考にCLIアプリケーションを作ります。

https://docs.micronaut.io/latest/guide/index.html#commandLineApps

micronaut-cli-sampleという名前でプロジェクトを作成します。 mavenでビルドを行いたいので、"--build maven"を追加しています。 また、ネイティブモジュールを作成するため、"--features graal-native-image"を追加しています。

mn create-cli-app micronaut-cli-sample --build maven --features graal-native-image

サンプルはPicocliを利用していますが、GraalVMではリフレクションに制限があるため利用しませんでした。 リフレクションを利用する場合に定義ファイルを利用する方法が以下に書かれています。

https://picocli.info/picocli-on-graalvm.html

以下のようなmainメソッドを実装した簡単な実装を用意しました。

package micronaut.cli.sample;

public class MicronautCliSampleCommand {

    public static void main(String[] args) throws Exception {
        System.out.println("Hello micronaut");
    }

}

まずjarをビルドしてみます。

mvn package

実行しています。

java -jar target/micronaut-cli-sample-0.1.jar -v
Hello micronaut

正常に実行出来ました。当たり前ですが、実行にはjavaが必要です。

バイナリ出力に挑戦

javaコマンドで実行を確認出来たので、javaに依存しないバイナリの出力に挑戦したいと思います。 以下を参考に作業を進めます。

https://docs.micronaut.io/latest/guide/index.html#graal

GraalVMを利用するにあたり、今回はDockerを利用してビルドします。 プロジェクトの直下のDockerfileを利用する設定になっていますが、この記述を参考に行かのうようにしてビルドしてみます。

docker build . -t micronaut-cli-sample

しかし、以下のエラーが発生してしまいました。

Error: Main entry point class 'target/micronaut-cli-sample-0.1-shaded.jar' not found.
Error: Use -H:+ReportExceptionStackTraces to print stacktrace of underlying exception
Error: Image build request failed with exit status 1

クラスが正しく認識されていないようなので、クラスを指定するためDockerfileの4行目を以下のように変更してみましたが、エラーは変わりません。

RUN native-image --no-server -cp target/micronaut-cli-sample-*.jar -H:Class=micronaut.cli.sample.MicronautCliSampleCommand

"--verbose"オプションを付けて実行してみましたが、反映されていないようです。 そこで、エラーログをもとにDockerの4行目を以下のように書き換えて実行することで、やっとビルドが開始されました。

RUN native-image --no-server -cp target/micronaut-cli-sample-0.1.jar -H:Class=micronaut.cli.sample.MicronautCliSampleCommand

ネット上でビルドに時間がかかるという書き込みを見ていたので覚悟は下のですが、60分近くかかった挙げ句、メモリ不足で失敗してしまいました。 Docker Toolbox経由で実行したのですが、VMに割り当てた1GBのメモリでは足りないようです。4GBへ増やしたところ5分程度でビルドが完了しました。

ビルドが成功したので早速実行したみたところ、以下のエラーが発生しました。

docker run --network host micronaut-cli-sample
Exception in thread "main" com.oracle.svm.core.jdk.UnsupportedFeatureError: Accessing an URL protocol that was not enabled. The URL protocol http is supported but not enabled by default. It must be enabled by adding the --enable-url-protocols=http option to the native-image command.

Dockerfileの4行目に"--enable-url-protocols=http"オプションを追加してビルドを行います。

RUN native-image --no-server -cp target/micronaut-cli-sample-0.1.jar -H:Class=micronaut.cli.sample.MicronautCliSampleCommand --enable-url-protocols=http

実行すると、javaで実行したときど同じ結果が出力されました。

docker run --network host micronaut-cli-sample
Hello micronaut

まとめ

以上でJavaバッチ処理をネイティブモジュールにすることができました。 リフレクション以外もいくつか制約があるようですが、あらたに何か作る際は候補にしても面白いかもしれません。

Reactで実装したコンポーネントのマウント時に工夫したこと

現在、Webページにオーバーレイで表示されるパーツを、React + Redux + react-router + redux-form + redux-thunk を使用して、コンポーネントとして実装しようと考えています。その辺りの話は機能リリース後にしたいと思いますが、そのReactで実装したコンポーネントを実際に使用するためにページにマウントする時に工夫したことについて書きたいと思います。

ページ表示時の処理を抑える

create-react-appで生成されるサンプルやReactの簡単なサンプルコードを見ていると、基本的に以下のようにページ表示時にコンポーネントをマウントするようになってます。

index.html(マウントする対象)

  <body>
    <div id="root"></div>
  </body>

index.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

ReactDOM.render(<App />, document.getElementById('root'));

このやり方だと、コンポーネントのパーツを使わないユーザーでもページの表示時にReactのロード処理が実行されてしまいます。それを避けページ表示時の処理を減らすように、以下のようにパーツ表示(ボタンクリック)時にコンポーネントをマウントするようにしてみました。

index.html(マウントする対象)

  <body>
    <div id="root"></div>
    <a href="javascript:void(0);" id="entry">表示</a>
  </body>

index.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';


document.getElementById('entry').addEventListener('click', e => {
  const target = document.getElementById('root');
  const closeHandler = e => {
    ReactDOM.unmountComponentAtNode(target); // オーバーレイ閉じる用
  }
  ReactDOM.render(<App closeHandler={closeHandler}/>, target);
});

以上により、ページ表示時にReactのロード処理を回避することができると思います。

マウント時に初期パラメータを渡す

パーツを表示する時に区分やマスタの値、ログインユーザーの情報などを初期のパラメータとして渡したいと思いました。Reactのコンポーネントがマウントされた時にAPIを使用してサーバーから初期値を取得することもできますが、せっかく表示元ページをJSPで動的に出力しているので、余計な通信をしないでページに埋め込まれた値(JSON)をコンポーネントに渡すようにしてみました。

index.html(マウントする対象)

  <body>
    <div id="root"></div>
    <span id="initParam" style="display:none;">
      {"title":"フォームタイトル", "body":"フォーム説明文"}
    </span>
    <a href="javascript:void(0);" id="entry">表示</a>
  </body>

index.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';


document.getElementById('entry').addEventListener('click', e => {
  const initParam = document.getElementById('initParam').innerHTML;
  const target = document.getElementById('root');
  const closeHandler = e => {
    ReactDOM.unmountComponentAtNode(target); // オーバーレイ閉じる用
  }
  ReactDOM.render(
    <App param={initParam} closeHandler={closeHandler}/>, 
    target
  );
});

App.js

import React, { Component } from 'react';
import { Modal } from 'react-overlays';

class App extends Component {
  render() {
    const {closeHandler,param} = this.props;
    const initParam = JSON.parse(param); // 初期値取得
    return (
      <div className="assessment">
        <Modal
          onHide={closeHandler}
          show={true}
          className="modal"
        >
          <div className="dialog">
            <a onClick={closeHandler}>閉じる</a>
            <h1>{initParam.title}</h1>
            <p>{initParam.body}</p>
            本体
          </div>
        </Modal>
      </div>
    );
  }
}

以上で、呼び出し元のHTMLで設定されているタイトルや本文が表示されると思います。

Code Splittingをして読み込み量を抑える

Reactのソースをnpm run buildでビルドするとwebpackでjsファイルにパッキングされますが、Code Splittingをすることで一度に全てのスクリプトを読み込まずに、必要なタイミングでロードすることができるようになります。この仕組みを使うと、ページ表示時に取得するスクリプトのサイズを抑えることができるので、初期表示を速くすることができると思います。

Code Splittingを実現するためにはdynamic importという仕組みを使うのですが、今回はloadable-componentsというライブラリを使用してimportすることで実現しました。

index.js

import React from 'react';
import ReactDOM from 'react-dom';
import loadable from 'loadable-components'

const App = loadable(() => import('./App')); // importの仕方を変更

document.getElementById('entry').addEventListener('click', e => {
  const initParam = document.getElementById('initParam').innerHTML;
  const target = document.getElementById('root');
  const closeHandler = e => {
    ReactDOM.unmountComponentAtNode(target); // オーバーレイ閉じる用
  }
  ReactDOM.render(
    <App param={initParam} closeHandler={closeHandler}/>, 
    target
  );
});

この対応により、テストコードでもページ表示時の読み込み量が2/3程度になりました。

f:id:eatsmart:20190425163102p:plain
Code Splitting前

f:id:eatsmart:20190425162945p:plain
Code Splitting後

Code SplittingはRouteごとに分割するのが良さそうで、実は今回はAppの中身はreact-routerでルーティングするので、App.jsにも使ってみました。

App.js

import React, { Component } from 'react';
import { Modal } from 'react-overlays';
import {Route, Switch } from 'react-router-dom'
import loadable from 'loadable-components'

class App extends Component {
  render() {
    const {closeHandler,param} = this.props;
    const initParam = JSON.parse(param);
    return (
      <div className="assessment">
        <Modal
          onHide={closeHandler}
          show={true}
          className="modal"
        >
          <div className="dialog">
            <a onClick={closeHandler}>閉じる</a>
            <h1>{initParam.title}</h1>
            <p>{initParam.body}</p>
            <Route component={AppRoute} />
          </div>
        </Modal>
      </div>
    );
  }
}

const Main = loadable(() => import('./Main'));
const Product = loadable(() => import('./product/Product'));
const Comment = loadable(() => import('./comment/Comment'));

const AppRoute = (props) => (
  <Switch>
    <Route path="/product" component={Product} />
    <Route path="/comment" component={Comment} />
    <Route component={Main} />
  </Switch>
)

export default App;

この対応で、コンポーネント内のページも必要に応じてスクリプトがロードされるようになると思います。

最後に

今回、Webページ上のオーバーレイ表示の中で、ページ遷移を含むSPA的なコンポーネントを実装するためにReactを検討しました。 Reactを使用することで、直接DOMを意識することなくUIを実装することができ、開発は楽になりそうでしたが、実際のページ表示やパフォーマンスが耐えうるものか不安がありました。

この記事のような工夫をすることで、実運用に採用できそうだなと思っています。

Javaのequalsメソッドから学んだこと

イートスマートの新人エンジニアが、業務の中でコードレビューを受けて学んだことについて書いてみたいと思います。Javaのequalsメソッドを使った例を取り上げます。

Javaのequalsメソッドについて

if (a.equals(b)) {以下省略...のようにaとbが文字列の場合に、文字列aとbが等価であるかどうかを判断し、オブジェクト同士の値が等しければtrueを返します。数値の比較の際には、==を使います。これは、数値がプリミティブ型であるため可能になります。文字列は、オブジェクト型になるため文字列同士をa==bのように比較するとa,bがもっているメモリ上のアドレスを参照して比較を行うことになります。つまり、aとbが同じ値かどうかではなく、同一インスタンスであるかを判断することになります。そのため、文字列の値を比較する際にはequalsで比較するようにします。

実装例

以下に実装例を示します。今回は、実際に業務で使用したコードをアレンジして、元号についての クラスを用意し、01であれば「令和」、02であれば「平成」を返すという仕様で考えてみます。

元号についてのクラス

public class Gengo {
 private String gengo;

    public String getGengoKbn() {
     return gengo;
    }

    public void setGengoKbn(String gengo) {
        this.gengo = gengo;
    }

    public String getGengoKbn() {
       if (定数クラス.GENGO_KBN_REIWA.equals(gengo)) {
        return "令和";
          if (定数クラス.GENGO_KBN_HEISEI.equals(gengo)) {
                return "平成";
        } 
        throw new IllegalStateException();
    }   
}

定数についてのクラス

   /** 令和を01とする */
public static final String GENGO_KBN_REIWA = "01";
    /** 平成を02とする */
public static final String GENGO_KBN_HEISEI = "02";

実装のポイント

元号の判定をする条件分岐の部分が今回のポイントになります。 実装の際に、以下のようなコードを最初書いておりました。

   if (gengo).equals("01")) {
        return "令和";
        }
        return "平成";

実装例のコードと比較して、良くないポイントがいくつかあるので 以下に整理したいと思います。

①まず、変数gengoは"01"と"02"の他にnullや"03"などが意図せず入る場合も あり、NullPointerExceptionが発生する恐れがあります。 そのため、実装例のように定数.equalsの形にすると、例外の発生を防ぐことができます。

②また、"01"が具体的に何を指すのかが見たときにわかりづらいので、実装例のように 定数として別途定義しておく方が可読性が上がり、検索をかけるときにも"01"が何を 指すのかが明確になります。

さらに、定数クラスでpublic static finalを用いる理由として、staticにするとインスタンスを 作らずにフィールドの利用が可能で、finalにすることで値の変更を不可にできます。以上のこと から、定数としての宣言に用いられ、ここでは外部から利用したいためpublicとしています。

③また、例外をスローする処理もあった方が望ましいので、メソッドの呼び出しに対して オブジェクトの状態が不正の場合を考慮し、IllegalStateExceptionをスローしています。

以上になります。基本的なコードではありますが、後々のメンテナンスのしやすさ、バグを 未然に防ぐといったことをもっと意識する必要があると感じました。

クスパUI改修におけるトラフィックの影響について

今回は、クスパでエリア(地域)毎の料理教室情報を扱うスマートフォンページ(以降エリアページ)のUI改修におけるトラフィックの影響について記載します。

cookingschool.jp

エリアページの構成

エリアページ構成は下記の通りです。 f:id:eatsmart:20190416152655p:plain

UI改修する目的

東京のエリアページ等は、地域の絞り込み項目が多く、リストが長いと下部のエリアコンテンツ領域に到達しずらい点を改修する目的で、地域 or 習いたいことの絞り込みリストをアコーディオン化することにしました。

UI改修前

f:id:eatsmart:20190416202413p:plain

1回目の改修

1回目のUI改修では、エリアコンテンツ領域をファーストビューに表示して認知されやすい様にする為、地域/習いたいことの絞り込みリストをアコーディオンで全て閉じてみまた。
その結果、改修前に比べ、直帰率/離脱率(特に直帰率)が悪化しました。

1回目のUI改修

f:id:eatsmart:20190416202451p:plain

2回目の改修

1回目のUI改修で直帰率/離脱率が悪化した原因として

  1. リストが全て閉じていると、地域/習いたいことの絞り込みリストの存在を認識されにくい

  2. アコーディオンのUI自体が分かりずらい

との推測から、リストを一部表示するアコーディオンを採用してみました。

2回目のUI改修

f:id:eatsmart:20190416202624p:plain

UI改修を戻す

2回目のUI改修で離脱率は改修しましたが、直帰率がUI改修前に比べて思ったより回復しませんでした。
その原因として

  1. エリアアコーディオンのタップ数(「開く」ボタン)が増えてるため、目的の絞り込みリストにたどり着く前に直帰している可能性がある

  2. ランディングしてきたユーザは絞り込みリストが全部出ているほうがより認知されやすい

との推測から、アコーディンを廃止してUI改修前に戻してみました。

結論

アコーディンを廃止してUI改修前に戻した事で、離脱率/直帰率伴にほぼ改修前に戻りました。 結論としては、エリアページにランディングもしくは回遊してくるユーザーは、ページ下部のコンテンツを参照するよりも、「地域」もしくは「習いたいこと」で絞り込む挙動が最も求められており、その為にはアコーディン化するよりはリストを全て表示することが最適であるという結論に至りました。

今回のUI改修では、目的の効果は得られませんでしたが、何回かUI改修したことで改めてエリアページのユーザーのニーズを把握する事ができました。

etcdを使ってDockerのオーバーレイ・ネットワークを構築する

サービス環境ではSwarm modeを利用していますが、機能としてはオーバーレイ・ネットワークしか利用していないので、Swarm modeへの依存を無くせないか検討しています。 オーバーレイ・ネットワークを構築するにはキーバリュー・ストア・サービスが必要とのことなので、etcdをセットアップしてみました。

各Dockerノードでetcdを可動させクラスタを構築することを想定しています。 まずサーバserver01/server02の2台でクラスタを構築したあとにサーバserver03を追加しています。これは今後のサーバ追加を想定しているためです。 作業を行った環境は以下の通りです。

  • OS:Ubuntu 16.04
  • サーバ:server01(IP:10.1.1.101)/server02(IP:10.1.1.102)/server03(IP:10.1.1.103)

etcd

インストール

root@server01:~# apt-get install -y etcd

server01/server02のクラスタ構築

server01/server02の/etc/default/etcdへ以下を記述します。

ETCD_NAME="docker01" 
ETCD_DATA_DIR="/var/lib/etcd/" 
ETCD_INITIAL_CLUSTER_STATE="new" 
ETCD_INITIAL_CLUSTER_TOKEN="etcd-cluster-01" 
ETCD_INITIAL_CLUSTER="docker01=http://10.1.1.101:2380,docker02=http://10.1.1.102:2380" 
ETCD_INITIAL_ADVERTISE_PEER_URLS="http://10.1.1.101:2380" 
ETCD_ADVERTISE_CLIENT_URLS="http://0.0.0.0:2379" 
ETCD_LISTEN_PEER_URLS="http://10.1.1.101:2380" 
ETCD_LISTEN_CLIENT_URLS="http://0.0.0.0:2379" 
ETCD_NAME="docker02" 
ETCD_DATA_DIR="/var/lib/etcd/" 
ETCD_INITIAL_CLUSTER_STATE="new" 
ETCD_INITIAL_CLUSTER_TOKEN="etcd-cluster-01" 
ETCD_INITIAL_CLUSTER="docker01=http://10.1.1.101:2380,docker02=http://10.1.1.102:2380" 
ETCD_INITIAL_ADVERTISE_PEER_URLS="http://10.1.1.102:2380" 
ETCD_ADVERTISE_CLIENT_URLS="http://0.0.0.0:2379" 
ETCD_LISTEN_PEER_URLS="http://10.1.1.102:2380" 
ETCD_LISTEN_CLIENT_URLS="http://0.0.0.0:2379" 

サービスを起動して、メンバの一覧を確認します。

root@server01:~# etcdctl member list
1924cd26a959e750: name=docker02 peerURLs=http://10.1.1.102:2380 clientURLs=http://0.0.0.0:2379
dd62a02c7fd58775: name=docker01 peerURLs=http://10.1.1.101:2380 clientURLs=http://0.0.0.0:2379

root@server02:~# etcdctl member list
1924cd26a959e750: name=docker02 peerURLs=http://10.1.1.102:2380 clientURLs=http://0.0.0.0:2379
dd62a02c7fd58775: name=docker01 peerURLs=http://10.1.1.101:2380 clientURLs=http://0.0.0.0:2379

値が同期されることを確認します。

root@server01:~# etcdctl get key1
Error:  100: Key not found (/key1) [5]

root@server02:~# etcdctl get key1
Error:  100: Key not found (/key1) [5]

root@server01:~# etcdctl set key1 value1
value1

root@server01:~# etcdctl get key1
value1

root@server02:~# etcdctl get key1
value1

クラスタへserver03の追加

次にノードの追加を行います。 注意点として、ノードを追加する前に既存のクラスタへメンバを追加する必要がありました。 ここで出力された設定を、ノード追加時に設定することになります。

root@server01:~# etcdctl member add server03 https://10.1.1.103:2380
Added member named server03 with ID 11b064e58e1d461e to cluster

ETCD_NAME="server03" 
ETCD_INITIAL_CLUSTER="server03=https://10.1.1.103:2380,docker02=http://10.1.1.102:2380,docker01=http://10.1.1.101:2380" 
ETCD_INITIAL_CLUSTER_STATE="existing" 

メンバが追加されたことを確認します。 この段階では"unstarted"となっていることがわかります。

root@server01:~# etcdctl member list
11b064e58e1d461e[unstarted]: peerURLs=https://10.1.1.103:2380
1924cd26a959e750: name=docker02 peerURLs=http://10.1.1.102:2380 clientURLs=http://0.0.0.0:2379
dd62a02c7fd58775: name=docker01 peerURLs=http://10.1.1.101:2380 clientURLs=http://0.0.0.0:2379

root@server02:~# etcdctl member list
11b064e58e1d461e[unstarted]: peerURLs=https://10.1.1.103:2380
1924cd26a959e750: name=docker02 peerURLs=http://10.1.1.102:2380 clientURLs=http://0.0.0.0:2379
dd62a02c7fd58775: name=docker01 peerURLs=http://10.1.1.101:2380 clientURLs=http://0.0.0.0:2379

docker03の設定を行います。 以下に加え上記手順で出力された設定を/etc/default/etcへ追記します。

ETCD_DATA_DIR="/var/lib/etcd/" 
ETCD_INITIAL_CLUSTER_TOKEN="etcd-cluster-01" 
ETCD_INITIAL_ADVERTISE_PEER_URLS="http://10.1.1.103:2380" 
ETCD_ADVERTISE_CLIENT_URLS="http://0.0.0.0:2379" 
ETCD_LISTEN_PEER_URLS="http://10.1.1.103:2380" 
ETCD_LISTEN_CLIENT_URLS="http://0.0.0.0:2379" 

サービスを起動して、メンバの一覧を確認します。 docker03がメンバになっていることを確認出来ました。

root@server03:~# etcdctl member list
11b064e58e1d461e: name=docker03 peerURLs=https://10.1.1.103:2380 clientURLs=http://0.0.0.0:2379
1924cd26a959e750: name=docker02 peerURLs=http://10.1.1.102:2380 clientURLs=http://0.0.0.0:2379
dd62a02c7fd58775: name=docker01 peerURLs=http://10.1.1.101:2380 clientURLs=http://0.0.0.0:2379
root@server01:~# etcdctl member list
11b064e58e1d461e: name=docker03 peerURLs=https://10.1.1.103:2380 clientURLs=http://0.0.0.0:2379
1924cd26a959e750: name=docker02 peerURLs=http://10.1.1.102:2380 clientURLs=http://0.0.0.0:2379
dd62a02c7fd58775: name=docker01 peerURLs=http://10.1.1.101:2380 clientURLs=http://0.0.0.0:2379
root@server02:~# etcdctl member list
11b064e58e1d461e: name=docker03 peerURLs=https://10.1.1.103:2380 clientURLs=http://0.0.0.0:2379
1924cd26a959e750: name=docker02 peerURLs=http://10.1.1.102:2380 clientURLs=http://0.0.0.0:2379
dd62a02c7fd58775: name=docker01 peerURLs=http://10.1.1.101:2380 clientURLs=http://0.0.0.0:2379

追加されたノードでも値が同期されることを確認します。

root@server03:~# etcdctl get key1
value1
root@server03:~# etcdctl set key2 value2
value2
root@server01:~# etcdctl get key2
value2
root@server02:~# etcdctl get key2
value2

クラスタからserver03の削除

最後にメンバの削除を行ってみます。 メンバの一覧から削除されたことが確認出来ます。

root@server01:~# etcdctl member remove 11b064e58e1d461e
Removed member 11b064e58e1d461e from cluster
root@server01:~# etcdctl member list
1924cd26a959e750: name=docker02 peerURLs=http://10.1.1.102:2380 clientURLs=http://0.0.0.0:2379
dd62a02c7fd58775: name=docker01 peerURLs=http://10.1.1.101:2380 clientURLs=http://0.0.0.0:2379

削除されたノードではメンバを参照出来ない状態になりました。 一度削除したノードを再度メンバに追加出来ないか試していますが、今のところ再度のセットアップが必要なようです。

root@server03:~# etcdctl member list
Error:  dial tcp 127.0.0.1:2379: getsockopt: connection refused

Docker

Swarm modeの停止

既存のコンテナをSwarmから離脱させます。 オプション無しだとエラーが発生したので"--force"を付けました。

root@server01:~# docker swarm leave --force
Node left the swarm.
root@server01:~# docker node ls
Error response from daemon: This node is not a swarm manager. Use "docker swarm init" or "docker swarm join" to connect this node to swarm and try again.

etcdの設定

/lib/systemd/system/docker.serviceを編集してetcdを利用するようにします。 各Dockerノードで可動するetcdを参照するため、"--cluster-store"には"etcd://127.0.0.1:2379"を指定しています。

ExecStart=/usr/bin/dockerd -H fd:// --cluster-store=etcd://127.0.0.1:2379 --cluster-advertise=<ホストのIPアドレス>:2376

"--cluster-advertise"にはネットワークインターフェース名を指定することも可能なようです。

ExecStart=/usr/bin/dockerd -H fd:// --cluster-store=etcd://127.0.0.1:2379 --cluster-advertise=<ネットワークインターフェース名>:2376

設定を反映させるためDockerを再起動します。

systemctl daemon-reload
systemctl restart docker

オーバーレイ・ネットワークの作成

Swarm modeの時と同じ手順でオーバーレイ・ネットワークを作成します。 新たにオーバーレイ・ネットワークが作成されていることが確認出来ました。

root@server01:~# docker network create -d overlay \
>   --subnet=10.1.3.0/24 \
>   --attachable \
>   -o "com.docker.network.bridge.mtu=8192" \
>   original-overlay-network
a8358f86a50c6c41b757d52f737c6828ff836f8e355b7b74ec1d8cc94bb02b22
root@server01:~# docker network ls
NETWORK ID          NAME                       DRIVER              SCOPE
38d745409fd6        bridge                     bridge              local
15d99e4689c0        docker_gwbridge            bridge              local
407cf1c223e9        host                       host                local
a8358f86a50c        original-overlay-network    overlay             global
c98b6152f763        none                       null                local

Swarm modeとの違い

以上の手順でオーバーレイ・ネットワークが作成出来ましたが、コンテナをデプロイする段階で問題が発生しました。 Swarm modeではserver01/server02で"apache"という名前のコンテナを稼働させていました(Serviceではなく通常のコンテナ)。 これが、今回の作業後からserver02で起動に失敗するようになりました。

root@server02:~# docker run -d \
>   --name apache\
>   --network=original-overlay-network \
>   --restart=always \
>   registry.eatsmart.local/apache
dc432cc6723f32cfb7fd0890d624e9e2be2e3f0067492aa8021eff5ea107b19b
docker: Error response from daemon: endpoint with name apache already exists in network original-overlay-network.

ネットワーク上に"apache"という名前が既に存在するというエラーですが、そもそもなぜいままでは起動できていたのか不思議です。 そこで以前のネットワークを確認してみました。

Swarm modeのオーバーレイ・ネットワーク

root@server01:~# docker network inspect original-overlay-network
[
    {
        "Name": "original-overlay-network",
        "Id": "apx4l1858tkb9n5k1e7isqdyy",
        "Created": "2018-03-19T04:37:57.046347368+09:00",
        "Scope": "swarm",
        "Driver": "overlay",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": null,
            "Config": [
                {
                    "Subnet": "10.1.3.0/24",
                    "Gateway": "10.1.3.1" 
                }
            ]
        },
        "Internal": false,
        "Attachable": true,
        "Ingress": false,
        "ConfigFrom": {
            "Network": "" 
        },
        "ConfigOnly": false,
        "Containers": {
            "46242739d12ca4fa562f01d0ea6b8db8d544d532548a50d2573bc79717df46c0": {
                "Name": "app01",
                "EndpointID": "027dd422df8d3b1b20895ba0a67e94bd2f76112c29d579a4feaf948eb312197d",
                "MacAddress": "02:42:0a:01:03:b4",
                "IPv4Address": "10.1.3.180/24",
                "IPv6Address": "" 
            },
            "d90d007a1b7b91fb2cf4524065392c17f0c213e938ca6853f6c59f0a6d1a0963": {
                "Name": "app02",
                "EndpointID": "cd5e42c4c82446ed8e354cf9d4d372b66bab7fdcb68e1ff4d09846b3f522e9ab",
                "MacAddress": "02:42:0a:01:03:b2",
                "IPv4Address": "10.1.3.178/24",
                "IPv6Address": "" 
            },
            "f48e32b2e77ab3883f8e622db07d63aeb097f6a59b2994411040ed15a1218e57": {
                "Name": "apache",
                "EndpointID": "593ab983633bce29230055c2ba357667b3bb7ea7a5a46a58e41e00ba91bbd366",
                "MacAddress": "02:42:0a:01:03:e5",
                "IPv4Address": "10.1.3.229/24",
                "IPv6Address": "" 
            }
        },
        "Options": {
            "com.docker.network.bridge.mtu": "8192",
            "com.docker.network.driver.overlay.vxlanid_list": "4099" 
        },
        "Labels": {},
        "Peers": [
            {
                "Name": "1aa3da59d05e",
                "IP": "10.1.1.101" 
            },
            {
                "Name": "6ea9bd5a9413",
                "IP": "10.1.1.102" 
            },
            {
                "Name": "24914465b868",
                "IP": "10.1.1.103" 
            }
        ]
    }
]

etcdを利用して作成したオーバーレイ・ネットワーク

root@server01:~# docker network inspect original-overlay-network
[
    {
        "Name": "original-overlay-network",
        "Id": "a8358f86a50c6c41b757d52f737c6828ff836f8e355b7b74ec1d8cc94bb02b22",
        "Created": "2019-03-22T15:52:50.259468549+09:00",
        "Scope": "global",
        "Driver": "overlay",
        "EnableIPv6": false,
        "IPAM": {
            "Driver": "default",
            "Options": {},
            "Config": [
                {
                    "Subnet": "10.1.3.0/24" 
                }
            ]
        },
        "Internal": false,
        "Attachable": true,
        "Ingress": false,
        "ConfigFrom": {
            "Network": "" 
        },
        "ConfigOnly": false,
        "Containers": {
            "ep-1544411a0c1fbbf03b085a24486e517657274426cac181c9275444696fb6b6bc": {
                "Name": "app01",
                "EndpointID": "1544411a0c1fbbf03b085a24486e517657274426cac181c9275444696fb6b6bc",
                "MacAddress": "02:42:0a:01:03:07",
                "IPv4Address": "10.1.3.7/24",
                "IPv6Address": "" 
            },
            "ep-33d97bb106751e12add32c2696fb8bc902f78c035d4fb43aab248a9c6e6ef9b9": {
                "Name": "app02",
                "EndpointID": "33d97bb106751e12add32c2696fb8bc902f78c035d4fb43aab248a9c6e6ef9b9",
                "MacAddress": "02:42:0a:01:03:05",
                "IPv4Address": "10.1.3.5/24",
                "IPv6Address": "" 
            },
            "ep-39ec8fe7b8e664319743d25fd52aa3bc7942e118f827a6d92dcc30ddf9b71327": {
                "Name": "app03",
                "EndpointID": "39ec8fe7b8e664319743d25fd52aa3bc7942e118f827a6d92dcc30ddf9b71327",
                "MacAddress": "",
                "IPv4Address": "10.1.3.6/24",
                "IPv6Address": "" 
            },
            "ep-4a01bd151cef82b92c298d7840883c63490087b1bdedffc2d7bd17bc5c08e6dc": {
                "Name": "app04",
                "EndpointID": "4a01bd151cef82b92c298d7840883c63490087b1bdedffc2d7bd17bc5c08e6dc",
                "MacAddress": "",
                "IPv4Address": "10.1.3.8/24",
                "IPv6Address": "" 
            },
            "ep-65f59ec16192b5a38cb9b8315e665dac6f9d43a695495603513038241e461b6d": {
                "Name": "elasticsearch",
                "EndpointID": "65f59ec16192b5a38cb9b8315e665dac6f9d43a695495603513038241e461b6d",
                "MacAddress": "02:42:0a:01:03:03",
                "IPv4Address": "10.1.3.3/24",
                "IPv6Address": "" 
            },
            "ep-93a3c1c16f0ce3fac1e2355f562246b046c01848b40455890d3985baad2e6b7d": {
                "Name": "apache",
                "EndpointID": "93a3c1c16f0ce3fac1e2355f562246b046c01848b40455890d3985baad2e6b7d",
                "MacAddress": "02:42:0a:01:03:04",
                "IPv4Address": "10.1.3.4/24",
                "IPv6Address": "" 
            },
            "ep-aecf02608f3be4ad0dadb4073c0b56fe3f12af0d5e52c89f7ede3dc1be57096c": {
                "Name": "redis",
                "EndpointID": "aecf02608f3be4ad0dadb4073c0b56fe3f12af0d5e52c89f7ede3dc1be57096c",
                "MacAddress": "02:42:0a:01:03:02",
                "IPv4Address": "10.1.3.2/24",
                "IPv6Address": "" 
            }
        },
        "Options": {
            "com.docker.network.bridge.mtu": "8192" 
        },
        "Labels": {}
    }
]

内容を比較してみると、以前のContainersにはserver01で稼働しているコンテナのみが含まれています。 これに対して現在のContainersにはオーバーレイ・ネットワークに含まれる全てのコンテナが含まれています。 このことから、以前はサーバごとにネットワークが別れていたため同じ名前のコンテナが別のノードで稼働出来たものと思われます。

まとめ

etcdを利用してオーバーレイ・ネットワーク構築してみました。 これで、Swarm modeの停止に向けた準備が整いました。 Swarm modeで作成したオーバーレイ・ネットワークからそのまま切り替えることは難しいようですが、リリースに向けて動作の確認を進めていく予定です。