---
# System prepended metadata

title: K8s Hands-on ②Frontendとの連携まで
tags: [Infrastructure]

---

---
lang: ja-jp
tags: Infrastructure
---
# K8s Hands-on ②Frontendとの連携まで
[Hands-on: Index](https://hackmd.io/@moriaki3193/ByxAsfPAH)

:::success
**Keywords**
- Kubernetes Resouces
    - Service, Ingress
- Reverse Proxy
:::

## Overview

![](https://i.imgur.com/j1SG5al.png)

## 顧客からの要望
- 花弁の長さやがく片の幅といった情報から、その花の品種を推定するアプリが欲しい
- 予測をWeb APIとして提供して欲しい
- デモを用意して、ユーザに簡単に触ってもらえるようにしたい ← ==イマココ==

## React Web App編
Webブラウザ上で動作するアプリを、Node.jsを利用して開発していきます。

### 開発準備
[このページ](https://nodejs.org/ja/)から、安定版のNode.jsをダウンロードし、セットアップウィザードの指示にしたがってインストールします。`ndenv`や`nodebrew`を利用してバージョン管理をしている場合には、そちらを利用してインストールを行っても良いです。

また、パッケージマネージャとして、Yarnを利用します。[このページ](https://yarnpkg.com/lang/ja/docs/install)から、ローカルの開発環境に合わせたものをダウンロードし、インストールしていきます。

次のような出力が得られれば、開発準備は完了です。
```shell=
$ node -v
# v12.14.0

$ yarn -v
# 1.21.1
```

### ローカルで開発
#### プロジェクトの初期化
まずはフロントエンドアプリケーションのプロジェクトをセットアップしていきましょう。また、効率的にアプリケーションを構築するために、Facebookが公開している[create-react-app](https://ja.reactjs.org/docs/create-a-new-react-app.html)を利用します。

```shell=
$ pwd  # /path/to/$PROJECT_NAME

# ローカル開発環境にcreate-react-appをインストールする
$ yarn global add create-react-app

# フロントエンドのアプリケーションプロジェクトを作成する
$ yarn create react-app frontend
```

ここまで問題なく実行できれば、`frontend`ディレクトリ以下は次のようなファイル・フォルダ構成となっているはずです。

```txt=
.
├── README.md
├── node_modules
├── package.json
├── public
├── src
└── yarn.lock
```

実は、すでにこの状態でフロントエンドアプリケーションが起動できる状態になっています。一度立ち上げてみましょう。

```shell=
$ pwd  # /path/to/$PROJECT_NAME/frontend

# npm scriptsでフロントエンドアプリケーションを返すサーバを起動する
$ yarn start
```

ブラウザが立ち上がり、次のような画面を確認できれば成功です！

![](https://i.imgur.com/F3Aii3O.png)

#### 【実装】 デモアプリの作成
前回のHands-onで実装したBackend APIの入出力は[こちら](/lm6GNTbdQj-NzFEG8OJ78Q#【実装】-Mock-API-endpointの追加)のようになっていました。
このエンドポイントに何らかの値をPOSTし、レスポンスを表示するだけの簡単なデモアプリを作成していきます。

##### 必要なパッケージのインストール
Sassを利用したスタイルのライブラリとして、[bulma](https://bulma.io/)を利用します。また、JavaScriptへのトランスパイルを可能にするために、`node-sass`を同梱します。

さらに、作成したBackend APIにリクエストを送るために[axios](https://github.com/axios/axios)を、ReactにおけるStateの安全な更新のヘルパーとして`immutability-helper`をここでは利用します。

以下のコマンドを実行して、依存するパッケージを更新しましょう。

```shell=
$ pwd  # /path/to/$PROJECT_NAME/frontend

$ yarn add bulma node-sass axios immutability-helper
```

##### React Componentの編集
`frontend/src/App.js`を開き、内容を次のように更新しましょう。

:::info
Reactの仕組みや記法については、このハンズオンの対象範囲外とします（知りたい場合には簡単な説明程度は行います）。
:::

```javascript=
import React from 'react';
import axios from 'axios';
import update from 'immutability-helper';
import './App.scss';

class App extends React.Component {

  constructor(props) {
    super(props);
    /* state */
    this.state = {
      data: {
        sepalLength: 0,  // がく片の長さ
        sepalWidth: 0,   // がく片の幅
        petalLength: 0,  // 花弁の長さ
        petalWidth: 0    // 花弁の幅
      },
      result: ''
    }
    /* event handlers */
    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
    this.clearState = this.clearState.bind(this);
  }

  handleChange(e) {
    const currentData = this.state.data;
    const key = e.target.name;
    const val = e.target.value;
    const updatedData = update(currentData, { [key]: { $set: val } });
    this.setState({ data: updatedData });
  }

  handleSubmit(e) {
    const headers = {
      'Access-Control-Allow-Origin': '*',
      'Content-Type': 'application/json'
    };
    const data = this.state.data;

    const apiUrl = 'http://localhost:5000/estimate';

    axios
      .post(apiUrl, data, { headers: headers })
      .then(res => {
        if (res.status === 200) {
          const result = res.data.result;
          const currentState = this.state;
          const updatedState = update(currentState, {
            'result': { $set: result }
          });
          this.setState(updatedState);
        } else {
          console.error('something went wrong');
        }
      });
  }

  clearState(e) {
    this.setState({
      data: {
        sepalLength: 0,  // がく片の長さ
        sepalWidth: 0,   // がく片の幅
        petalLength: 0,  // 花弁の長さ
        petalWidth: 0    // 花弁の幅
      },
      result: ''
    });
  }

  isFilled() {
    const data = this.state.data;
    let b = (data.sepalLength > 0);
    b &= (data.sepalWidth > 0);
    b &= (data.petalLength > 0);
    b &= (data.petalWidth > 0);
    return b;
  }

  render() {
    const inputFields = [
      ['がく片の長さ', 'sepalLength'],
      ['がく片の幅', 'sepalWidth'],
      ['花弁の長さ', 'petalLength'],
      ['花弁の幅', 'petalWidth']
    ];

    return (
      <div className='container'>
        <h1 className='title'>K8s Hands-on</h1>
        <p className='subtitle'>Iris dataset</p>
        {Array.from({length: 4}, (v, k) => k).map(idx => {
          const [label, name] = inputFields[idx];

          return (
            <React.Fragment>
              <label className='label'>{label}</label>
              <div className='field'>
                <div className='control has-icons-left'>
                  <input
                    name={name}
                    className='input'
                    type='number'
                    min='0'
                    max='10'
                    step='0.1'
                    placeholder='0'
                    value={this.state.data[name]}
                    onChange={this.handleChange}
                  />
                  <span className='icon is-small is-left'>
                    <i className='fas fa-arrows-alt-h'></i>
                  </span>
                </div>
              </div>
            </React.Fragment>
          );
        })}
        <div className="level is-mobile">
          <div className="level-item has-text-centered">
            <div>
              <p className="heading">予測結果</p>
              <p className="title">{this.state.result}</p>
            </div>
          </div>
        </div>
        <div className="field is-grouped">
          <div className="control is-expanded">
            <button
              className="button is-text is-fullwidth"
              onClick={this.clearState}>
              クリア
            </button>
          </div>
          <div className="control is-expanded">
            <button
              className="button is-link is-fullwidth"
              disabled={!this.isFilled.call(this)}
              onClick={this.handleSubmit}>
              予測する
            </button>
          </div>
        </div>
      </div>
    );
  }

}

export default App;
```

##### スタイルの更新
デモの段階では冗長ですが、せっかくなので簡単にスタイルを更新しておきます。

まずは`frontend/public/index.html`を開き、内容を次のように更新します。

```htmlmixed=
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
    <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/bulma@0.8.0/css/bulma.min.css" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
    <title>K8s Hands-on</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <section id='MainContainer' class='section'>
      <div id="root"></div>
    </section>
    <script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
  </body>
</html>
```

次に、`frontend/src/App.css`を`frontend/src/App.scss`へと名称を変更し、次のような内容に書き換えます。

```sass=
@charset "utf-8";

// Import a Google Font
@import url('https://fonts.googleapis.com/css?family=Nunito:400,700');

// Set your brand colors
$purple: #8A4D76;
$pink: #FA7C91;
$brown: #757763;
$beige-light: #D0D1CD;
$beige-lighter: #EFF0EB;

// Update Bulma's global variables
$family-sans-serif: "Nunito", sans-serif;
$grey-dark: $brown;
$grey-light: $beige-light;
$primary: $purple;
$link: $pink;
$widescreen-enabled: false;
$fullhd-enabled: false;

// Update some of Bulma's component variables
$body-background-color: $beige-lighter;
$control-border-width: 2px;
$input-border-color: transparent;
$input-shadow: none;

// Import only what you need from Bulma
@import "../node_modules/bulma/sass/utilities/_all.sass";
@import "../node_modules/bulma/sass/base/_all.sass";
@import "../node_modules/bulma/sass/elements/button.sass";
@import "../node_modules/bulma/sass/elements/container.sass";
@import "../node_modules/bulma/sass/elements/form.sass";
@import "../node_modules/bulma/sass/elements/title.sass";
// @import "../node_modules/bulma/sass/layout/hero.sass";
@import "../node_modules/bulma/sass/layout/section.sass";
```

### アプリのコンテナ化
イメージのNode.jsのバージョンは、ローカルにインストールされているものを利用してください。

```Dockerfile=
FROM node:12.14.0-alpine3.10

LABEL maintainer="YOUR NAME <you@sample.com>"

WORKDIR /workspace

RUN apk update && \
    apk add tzdata yarn && \
    cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime && \
    apk del tzdata && \
    rm -rf /var/cache/apk/*

COPY package.json yarn.lock ./

RUN yarn global add serve && \
    yarn install

COPY public ./public
COPY src ./src

RUN yarn build && \
    rm -rf ./src

CMD ["serve", "-s", "-l", "3000", "build"]
```

イメージをビルドして、コンテナ化されたアプリケーションの動作確認をします。
```shell=
$ pwd  # /path/to/$PROJECT_NAME

$ docker build -f dockerfiles/frontend.Dockerfile -t gcr.io/$PROJECT_ID/k8s-hands-on-frontend:v1 ./frontend
```

次のコマンドを実行して、作成したアプリケーションの見た目を確認してみましょう！
```shell=
# バックエンドAPIサーバの起動
$ docker run --rm --name k8s-hands-on-backend --publish 5000:5000 gcr.io/$PROJECT_ID/k8s-hands-on-backend:v1

# フロントエンドアプリケーションの起動
$ docker run --rm --name k8s-hands-on-frontend --publish 3000:3000 gcr.io/$PROJECT_ID/k8s-hands-on-frontend:v1
# ブラウザを開き、http://localhost:3000にアクセスしてみましょう
```

:::warning
ここまでで見た目は完成しているはずですが、ブラウザによっては`CORS(Cross-Origin Resource Sharing)`の制約により、フロントエンドアプリケーションからバックエンドAPIへのリクエストが行えない状態になっています。この問題は次のセクションにて対応します。

CORSについては[こちら](https://developer.mozilla.org/ja/docs/Web/HTTP/CORS)の説明がわかりやすいです。
:::

#### Backend APIの更新
Flaskには幸いにも、CORSの問題を簡単に解決するためのプラグインが提供されています。

```shell=
$ pwd  # /path/to/$PROJECT_NAME/backend

$ pipenv install flask-cors
```

`backend/server.py`に以下の行を追加してください。

```python=
# L7: from flask import Flask, jsonify, request
from flask_cors import CORS  # ← 追加

# (中略)

# L12: app = Flask(__name__)
CORS(app)  # ← 追加

# クラスタにデプロイした際のコンテキストの解決がしやすいように
# /estimate → /api/estimate にパスを更新する
# L23: @app.route('/estimate', methods=['POST'])
@app.route('/api/estimate', methods=['POST'])
...
```

編集が完了したら、再度イメージをビルドし、きちんとフロントエンドアプリケーションと連携が取れていることを確認してみましょう。

```shell=
$ pwd  # /path/to/$PROJECT_NAME

# イメージの更新: タグをv2に！
$ docker build -t gcr.io/$PROJECT_ID/k8s-hands-on-backend:v2 -f ./dockerfiles/backend.Dockerfile ./backend

# バックエンドAPIサーバの起動
$ docker run --rm --name k8s-hands-on-backend --publish 5000:5000 gcr.io/$PROJECT_ID/k8s-hands-on-backend:v2
```

それでは、再び`http://localhost:3000`にアクセスし、コンテナ化されたアプリケーションどうしが正しく連携できていることを確認しましょう！

:::info
実際のプロダクトの本番環境では、CORSに正しく対応する必要がありますが、ここでは割愛します。
:::

## クラスタへのデプロイ
（構成図をこの辺に貼る）

（何が困るのかの説明もここで行う）

### エンドポイントの更新とイメージのPush
- `/`と`/*`と`/static/*/*`のリソース要求に対してはfrontendのサーバにルーティングする
- `/api/`のリソース要求に対してはbackendのサーバにルーティングする

#### App.jsの更新
ネットワーク上では、`/api/estimate`にリクエストすることになるので、`App.js`を更新します。

```javascript=
// L41: 次の内容を更新する
// const apiUrl = 'http://localhost:5000/estimate';
const apiUrl = '/api/estimate';
```

#### GCRへイメージをPushする
```shell=
$ pwd  # /path/to/$PROJECT_NAME

$ docker build -t gcr.io/$PROJECT_ID/k8s-hands-on-backend:v2 -f ./dockerfiles/backend.Dockerfile ./backend

$ gcloud docker -- push gcr.io/$PROJECT_ID/k8s-hands-on-backend:v2

$ docker build -t gcr.io/$PROJECT_ID/k8s-hands-on-frontend:v1 -f ./dockerfiles/frontend.Dockerfile ./frontend

$ gcloud docker -- push gcr.io/$PROJECT_ID/k8s-hands-on-frontend:v1
```

### Deployment.yamlの更新
フロントエンドのイメージをPodに含めるように更新します。

```yaml=
apiVersion: apps/v1
kind: Deployment
metadata:
  name: k8s-hands-on-deployment
  labels:
    app: k8s-hands-on
spec:
  replicas: 1
  selector:
    matchLabels:
      app: k8s-hands-on-pod
  template:
    metadata:
      labels:
        app: k8s-hands-on-pod
    spec:
      containers:
      - name: k8s-hands-on-frontend
        image: gcr.io/$PROJECT_ID/k8s-hands-on-frontend:v1
        ports:
        - containerPort: 3000
      - name: k8s-hands-on-backend
        image: gcr.io/$PROJECT_ID/k8s-hands-on-backend:v2
        ports:
        - containerPort: 5000
```

### Service.yamlの更新
`type=LoadBalancer`から、`type=NodePort`に変更します。

```yaml=
apiVersion: v1
kind: Service
metadata:
  name: k8s-hands-on-service
  labels:
    app: k8s-hands-on
spec:
  type: NodePort
  ports:
  - name: frontend-port
    port: 3000
    targetPort: 3000
    protocol: TCP
  - name: backend-port
    port: 5000
    targetPort: 5000
    protocol: TCP
  selector:
    app: k8s-hands-on-pod
```

### Ingress.yamlの追加
（[このへん](https://matthewpalmer.net/kubernetes-app-developer/articles/kubernetes-ingress-guide-nginx-example.html)を引用して説明する）
（[自分で調べたやつ](https://hackmd.io/@moriaki3193/r1SO7o7AS)）

```yaml=
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: k8s-hands-on-ingress
  labels:
    app: k8s-hands-on
spec:
  rules:
  - http:
      paths:  # 順番に注意！
      - path: /api/estimate
        backend:
          serviceName: k8s-hands-on-service
          servicePort: 5000
      - path: /
        backend:
          serviceName: k8s-hands-on-service
          servicePort: 3000
```

GCP Consoleを開き、Ingressが有効になったら、公開されたIPアドレスを利用して（スマホから）アクセスしてみましょう！

## まとめ
このチュートリアルでは、次のようなことを学びました。

- 同一Pod内に複数のコンテナを同梱する方法
- 複数サービスをIngressリソースでリバースプロキシする方法

次回の[③モデルのデプロイまで](https://hackmd.io/@moriaki3193/SJSinzwRB)では、現状ではMock APIとなっているものの中身を学習済みの機械学習に置き換える方法について見ていきます。その際に、よりリッチなノード（VMインスタンス）へとスケールアウトする必要が出てくるので、K8sを通じたリソースの更新方法についても触れていきます。
