マイクロなマイクロサービスを作ってみた


# 経緯
社内でモノリシックなRailsアプリケーションを分割することを検討しています。
技術調査も兼ねて、試しにぷちマイクロサービス作ってみました。

# その後（追記）
__結論、マイクロサービスアーキテクチャは見送ることにしました。
「組織体制はアーキテクチャは相関関係がある」というコンウェイの法則があります。
マイクロサービスアーキテクチャは、大規模な官僚主義体制のチームなどに採用するとメリットがあるアーキテクチャなので、スタートアップ企業などは採用しないほうが良いとの結論に至りました。__


アーキテクチャ考えるのって難しいですね...


# 技術選定
フロントエンドには、社内で使用しているReactを使います。
バックエンドには、Railsでも良かったのですが、goを使ってみたかったのでgoのginというフレームワークを使います。

## フロントエンド
- React.js
- Firebase Authentication

## バックエンド
- Golang
- gin
- PostgreSQL

## その他
- Kubernetes
- Docker

# テーブル構成
本の情報が取得できるというシンプルなものにしました。

テーブル名：book

| カラム名 | タイプ | 備考 |
|:-----------|------------:|:------------:|
| id       | integer        | primary         |
| title       | string        |          |
| author       | string        |          |

# フロントエンド
Reactの簡単なアプリ作成
せっかくなのでFirebaseを使ってログイン機能を実装しました。
Firebase Authentication使うと認証機能が素早く作れるので、個人開発でサービス作るならこれな気がします。

## セットアップ

### React App 作成
```zsh
create-react-app auth-test
cd auth-test
```

### Reactに必要な機能のinstall
```
yarn add react-router-dom firebase bootstrap reactstrap react-loading-overlay formik yup axios
```

### ログイン機能の実装
こちらの記事を参考にさせてもらいました！（感謝です）
https://qiita.com/zaburo/items/801bd288cec47bd28764


### APIの内容表示の実装

```js:Home.js
import React from 'react';
import firebase from '../Firebase';
import { Link } from 'react-router-dom';
import { Button } from 'reactstrap';
import axios from 'axios';


class Home extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      booksList: []
    };
  }


  componentDidMount() {
    axios
      .get("/api/books")
      .then(res => this.setState({ booksList: res.data.data }))
      .catch(err => alert(err));
  }

  handleLogout = () => {
    firebase.auth().signOut();
  }

  render() {
    return (
      <div className="container">
        <p>Home</p>
        <ul>
          { this.state.booksList.map(book => <li>{book.id}:{book.title}:{book.author}</li>)}
        </ul>
        <Link to="/profile">Profileへ</Link>
        <br />
        <br />
        <Button onClick={this.handleLogout}>ログアウト</Button>
      </div>
    );
  }
}

export default Home;

```

### Docker
結構重いので軽量化検討できそう。

```docker:Dockerfile
FROM node:12.16.1
WORKDIR /usr/src/app
COPY ["package.json", "yarn.lock", "./"]
RUN yarn install
COPY . .
ENTRYPOINT [ "yarn", "start" ]
```

```yml:docker-compose.yml
version: '3'
services:
  node:
    build:
      context: .
    tty: true
    environment:
      - NODE_ENV=development
    volumes:
    - ./:/usr/src/app
    command: sh -c "yarn start"
    ports:
    - "3000:3000"
    stdin_open: true # https://teratail.com/questions/249875
```

#### docker hub へpush
あとでkubernetesで使うのでpush

```zsh
docker build -t hogehoge/auth-test:latest .
docker push hogehoge/auth-test:latest .
```

# バックエンド
## 準備
### gin
Golangのフレームワークが色々あったのですが、こちらが有名だったので使ってみました。
https://github.com/gin-gonic/gin

```zsh
brew install go
mkdir gin_test && cd gin_test
go mod init gin_test
go get -u github.com/gin-gonic/gin
```

## 実装
今回はMVC的なディレクトリ構造にして実装しました。

```go:main.go
package main

import (
	controllers "gin_test/controllers"
	"gin_test/models"
	"github.com/gin-gonic/gin"
)

func main() {
	r := gin.Default()

	db := models.SetupModels()

	r.Use(func(c *gin.Context){
		c.Set("db", db)
		c.Next()
	})

	r.GET("/books", controllers.FindBooks)
	r.POST("/books", controllers.CreateBook)
	r.GET("/books/:id", controllers.FindBook)
	r.PATCH("/books/:id", controllers.UpdateBook)
	r.DELETE("/books/:id", controllers.DeleteBook)
	r.Run()
}
```

```go:models/setup.go
package models

import (
	"fmt"
	"github.com/jinzhu/gorm"
	_ "github.com/jinzhu/gorm/dialects/postgres"
	"github.com/spf13/viper"
)

func SetupModels() *gorm.DB {
	viper.AutomaticEnv()

	viper_user := viper.Get("POSTGRES_USER")
	viper_password := viper.Get("POSTGRES_PASSWORD")
	viper_db := viper.Get("POSTGRES_DB")
	viper_host := viper.Get("POSTGRES_HOST")
	viper_port := viper.Get("POSTGRES_PORT")

	prosgret_conname := fmt.Sprintf("host=%v port=%v user=%v dbname=%v password=%v sslmode=disable", viper_host, viper_port, viper_user, viper_db, viper_password)

	fmt.Println("conname is\t\t", prosgret_conname)

	db, err := gorm.Open("postgres", prosgret_conname)
	if err != nil {
		panic("Failed to connect to database!")
	}

	db.AutoMigrate(&Book{})

	m := Book{Author: "author1", Title: "title1"}

	db.Create(&m)

	return db
}

```

```go:models/book.go
package models
type Book struct {
	ID uint `json:"id" gorm:"primary_key"`
	Title string `json:"title"`
	Author string `json:"author"`
}

type CreateBookInput struct {
	Title string `json:"title" binding:"required"`
	Author string `json:"author" binding:"required"`
}

type UpdateBookInput struct {
	Title string `json:"title"`
	Author string `json:"author"`
}
```

```go:controllers/book.go
package controllers

import (
	models "gin_test/models"
	"net/http"
	"github.com/gin-gonic/gin"
	"github.com/jinzhu/gorm"
)

func FindBooks(c *gin.Context) {
	db := c.MustGet("db").(*gorm.DB)

	var books []models.Book
	db.Find(&books)

	c.JSON(http.StatusOK, gin.H{"data": books})
}


func CreateBook(c *gin.Context) {
	db := c.MustGet("db").(*gorm.DB)
	var input models.CreateBookInput
	if err := c.ShouldBindJSON(&input); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
	}

	book := models.Book{Title: input.Author, Author: input.Author}
	db.Create(&book)

	c.JSON(http.StatusOK, gin.H{"data": book})
}

func FindBook(c *gin.Context) {
	db := c.MustGet("db").(*gorm.DB)

	var book models.Book
	if err := db.Where("id = ?", c.Param("id")).First(&book).Error; err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Record not found!"})
		return
	}

	c.JSON(http.StatusOK, gin.H{"data": book})
}

func UpdateBook(c *gin.Context) {
	db := c.MustGet("db").(*gorm.DB)

	var book models.Book
	if err := db.Where("id = ?", c.Param("id")).First(&book).Error; err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	var input models.UpdateBookInput
	if err := c.ShouldBindJSON(&input); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}
	db.Model(&book).Updates(input)

	c.JSON(http.StatusOK, gin.H{"data": book})
}

func DeleteBook(c *gin.Context) {
	db := c.MustGet("db").(*gorm.DB)

	var book models.Book
	if err := db.Where("id = ?", c.Param("id")).First(&book).Error; err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "Record not found!"})
		return
	}

	db.Delete(&book)

	c.JSON(http.StatusOK, gin.H{"data": true})
}
```

## Docker

```docker:Dockerfile
FROM golang@sha256:0991060a1447cf648bab7f6bb60335d1243930e38420bee8fec3db1267b84cfa as builder
ENV GO111MODULE=on
RUN apk update && apk add --no-cache git ca-certificates tzdata && update-ca-certificates
ENV USER=appuser
ENV UID=10001
RUN adduser \
   --disabled-password \
   --gecos "" \
   --home "/nonexistent" \
   --shell "/sbin/nologin" \
   --no-create-home \
   --uid "${UID}" \
   "${USER}"
WORKDIR $GOPATH/src/mypackage/myapp/
COPY . .
RUN go mod vendor
RUN ls
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /go/bin/hello -mod vendor main.go

FROM scratch
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group
COPY --from=builder /go/bin/hello /go/bin/hello
USER appuser:appuser
ENTRYPOINT ["/go/bin/hello"]
EXPOSE 8080
```

```yml:docker-compose.yaml
version: '3.7'
volumes:
  database_data:
    driver: local
services:
  db:
    image: 'postgres:latest'
    ports:
      - '5432:5432'
    expose:
      - 5432
    environment:
      POSTGRES_DB: postgres
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    volumes:
      - database_data:/var/lib/postgresql/data
  web:
    build: .
    ports:
      - '8080:8080'
    environment:
      POSTGRES_HOST: db
      POSTGRES_PORT: 5432
    env_file:
      - .env
    links:
      - db
```

## APIを試してみる
```zsh
docker-compose build
docker-compose up
curl http://localhost:8080/books
```
これでget取得できるはず。

## Docker hub へ push
これもあとでkubernetesで使います。
```zsh
docker build -t hogehoge/gin_test:latest .
docker push hogehoge/gin_test:latest .
```

# kubernetes
## 下準備
こちら参考にさせてもらいました！
https://qiita.com/kitsuki00/items/d69edca9d6d9af38fc95

## minikube
ローカルで試すなら必要なのでインストールしました。
https://kubernetes.io/ja/docs/tasks/access-application-cluster/ingress-minikube/

自分はインストール時にハマったんですが、以下のissueで解決できました。
https://github.com/kubernetes/minikube/issues/7332

```zsh
minikube config set vm-driver hyperkit
minikube delete
minikube start
minikube addons enable ingress
```

## 実装

### クライアント側

```client-cluster-ip-deployment.yaml
apiVersion: v1
kind: Service
metadata:
  name: client-cluster-ip-service
spec:
  type: ClusterIP
  selector:
    component: client
  ports:
  - port: 3000
    targetPort: 3000
```

```client-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: client-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      component: client
  template:
    metadata:
      labels:
        component: client
    spec:
      containers:
      - name: client
        image: ktoshiya/auth-test:latest
        ports:
        - containerPort: 3000
        resources:
          limits:
            memory: 512Mi
            cpu: "1"
          requests:
            memory: 256Mi
            cpu: "0.2"
        imagePullPolicy: Always
```

### API側

```api-cluster-ip-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: api-cluster-ip-service
spec:
  type: ClusterIP
  selector:
    component: api
  ports:
  - port: 8080
    targetPort: 8080
```

```api-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-deployment
spec:
  replicas: 1
  selector:
    matchLabels:
      component: api
  template:
    metadata:
      labels:
        component: api
    spec:
      containers:
      - name: api
        image: ktoshiya/gin-test:latest
        ports:
        - containerPort: 8080
        resources:
          limits:
            memory: 512Mi
            cpu: "1"
          requests:
            memory: 256Mi
            cpu: "0.2"
        imagePullPolicy: Always
        env:
        - name: POSTGRES_HOST
          value: psql.default.svc.cluster.local
        - name: POSTGRES_PORT
          value: '5432'
        - name: POSTGRES_DB
          value: postgres
        - name: POSTGRES_USER
          value: postgres
        - name: POSTGRES_PASSWORD
          value: postgres
```

### データベース

```postgres-cluster-ip-deployment.yaml
apiVersion: v1
kind: Service
metadata:
  name: psql
spec:
  type: ClusterIP
  ports:
    - name: "psql-port"
      protocol: "TCP"
      port: 5432
      targetPort: 5432
  selector:
    role: db
```

```postgres-deployment.yaml
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: init-db-sql
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: psql
spec:
  replicas: 1
  serviceName: psql
  selector:
    matchLabels:
      role: db
  template:
    metadata:
      labels:
        role: db
    spec:
      containers:
      - name: psql-container
        image: postgres:latest
        resources:
          limits:
            memory: 512Mi
            cpu: "1"
          requests:
            memory: 256Mi
            cpu: "0.2"
        env:
        - name: POSTGRES_DB
          value: postgres
        - name: POSTGRES_USER
          value: postgres
        - name: POSTGRES_PASSWORD
          value: postgres
        volumeMounts:
        - name: init-sql-configmap
          mountPath: /docker-entrypoint-initdb.d
        - name: datadir
          mountPath: /var/lib/postgresql/data
      volumes:
        - name: init-sql-configmap
          configMap:
            name: init-db-sql
  volumeClaimTemplates:
  - metadata:
      name: datadir
    spec:
      accessModes:
      - ReadWriteOnce
      resources:
        requests:
          storage: 5G
```

永続化に必要らしいのでこちらも入れる

```database-persistent-volume-claim.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: database-persistent-volume-claim
spec:
  accessModes:
  -   ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
```


### ingres
ロードバランサー的な役割をしてくれるらしいです。
今回は、検証しやすいように外部からもAPIにアクセスできるようにしました。

```ingress-service.yaml
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: ingress-service
  annotations:
    kubernetes.io/ingress.class: nginx
    nginx.ingress.kubernetes.io/rewrite-target: /$1
spec:
  rules:
    - http:
        paths:
          - path: /?(.*)
            backend:
              serviceName: client-cluster-ip-service
              servicePort: 3000
          - path: /api/?(.*)
            backend:
              serviceName: api-cluster-ip-service
              servicePort: 8080
```

## デプロイ
```
kubectl apply -f .
```

## minikube起動
```
minikube start
```

## ip確認
```
minikube ip
```

## 取得したipでclientに接続
```
http://192.168.64.2
```

## ダッシュボードでkubernetesの状態も確認できます。
```
minikube dashboard
```

ログインすると情報が表示されるはず

# 感想
半日くらいでサクッと作れました。これを本番環境で使えると便利そうです。
課題はいくつかあって、バックエンドはMVCのようなディレクトリ構造でしたが、DDDのようなディレクトリ構造のほうが良いかもとか
クライアント側にはまだ、POSTの実装をしていないのでそのうちしたいとか
開発環境にkubernetesを使うのは大げさな気がするので、開発環境用のdockerも作る必要ありそうとか
色々、取り入れるには残課題があるので、近々そこを解消していきたいと思ってます。