11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

VoicyAdvent Calendar 2020

Day 3

貧乏エンジニアが考える極力お金をかけないモダンなサーバーサイド環境 2020

Last updated at Posted at 2020-12-02

この記事は Voicy Advent Calendar 2020 の 3 日目の記事です。
先日は, @moonum さんの MVVM + LiveData + coroutine アーキテクチャにSpek2を使って資産価値のあるUnitTestを追加していく でした。明日は, @yamagenii さんの 0と1のキーボードでプログラミングしてみた です。

はじめに

「モダンな環境」魅力的ですよね。エンジニアである以上, 新しい技術を追い続けたいものです。今回は, そんなモダンな環境を極力お金をかけずに今実現するなら...??というところについて考えてみました。

一言で「モダン」といってもそこは人によって定義が異なるかなとは思います。
今回の「モダンな開発環境」は以下を満たしているという前提です。

  • アプリケーション
    • マイクロサービス化
    • レイヤードアーキテクチャ
  • インフラ
    • コード化されてる
    • パブリッククラウドにデプロイされてる
    • 環境が複数あることを想定出来てる
  • CI/CD あり
  • 外部ストレージへの永続化の考慮があるz

今回パブリッククラウドとしては, GCP を選択してます。(一番慣れているため)

結論

Untitled Diagram (1).png

大項目 種別 リソース
アプリケーション 言語 golang
アプリケーション プロトコル gRPC
インフラ コンピューティング Cloud Run
インフラ 外部ストレージ兼DBクライアント Compute Engine
インフラ IaC terraform
インフラ IaCリモートストレージ他 Cloud Storage
CI/CD - GitHub Actions(一部CloudBuildを経由)

レポジトリ→モノレポ。
モノレポ構成例。

├── .github
│   └── workflows
├── build
├── databases
├── deployments
│   ├── environments
│   │   ├── dev
│   │   └── prod
│   └── modules
│       ├── bastion_server
│       ├── firewall
│       └── vpc
├── docs
├── proto
│   ├── go
│   │   └── schema
│   └── schema
├── clients
├── bff
└── services
    ├── bar-service
    ├── foo-service
    └── hoge-service
  • .github: GitHub Actions
  • build: Dockerfile とか
  • databases: db のマイグレーションとか dockernize とか
  • deployments: terraform code
  • docs: ER図とかシーケンスとかあれば
  • proto: proto ファイル及び自動生成ファイル
  • clients: アプリケーションクライアント
  • bff: アプリケーションクライアントに対応するバックエンド(bffパターン)
  • services: ドメイン実装サービス

選定

レポジトリ

アプリケーションやインフラをどう管理していくかという点で最初に決めが必要になるのがここかなと思います。今回は個人開発ということもあり, 全体の把握しやすさを優先してモノレポで構成してみました。

アプリケーション

アプリケーション選定については予算が絡んでこないので自由です。お好きな言語, お好きなプロトコルでというところで今回はマイクロサービスと相性が良くエコシステムが発展している golang x gRPC を選択してみました。

マイクロサービス周辺のエコシステム(go)

サンプルや採用事例も多く実装に困らないです。

CI/CD

お金をかけないでというところから無料枠があるものが望ましいです。が, 大抵の CI ツールには無料枠が存在するので, ここも好みで良さそうです。選定対象は, CircleCI, Cloud Build, GitHub Actions, Travis ... etc のような感じです。今回モノレポなので, ディレクトリ単位での hook の分け方が容易であったり, 目的別で分けられるみたいなところから GitHub Actions を選びました。

外部ストレージ兼DBクライアント

ここが一番お金がかかるところです。GCP には「Always Free プロダクト」というのがあって特定のリソースは一定無料みたいなのがありこちらを利用します。

がっつりアプリケーション作りたいな...と考えたとき, firestore は 1GB と少し物足りなく, 他となったときの選択肢が GCE になります。

1 f1-micro インスタンス(1 か月あたり、北バージニア [us-east4] を除く米国リージョンのみ)
30 GB 月の HDD

というわけで, us-west1 に 30GB HDD な GCE を立てて外部ストレージサーバーとして稼働させることにしてみました。

コンピューティング

前提として, golang x gRPC なアプリケーションを稼働させる必要があります。

節約を考えてとりあえずデプロイ出来ればみたいな点を考えると, 上述の GCE に対して minikube などで k8s を立てるなどの選択肢もありそうです。ただ, 無料枠の容量としては限られているため極力ストレージ以外の用途では使いたくない都合があります。

そこで候補に上がるのが, Cloud Run か GKE というところです。
Cloud Run は無料枠も大きいですし, 直近 gRPC streaming 対応が入った。というところからの興味もあったので, Cloud Run を選択してみました。

IaC

これは terraform, ansible あるいは, GCP に Cloud Deployment Manager という terraform 互換のあるツールもあります。Cloud Deployment Manager については, コーディングを python で残せてより柔軟な IaC が可能。みたいなメリットがあるんですが, 過去使った感触では結構不具合とかつまづきポイントが多く扱いづらい印象でした。改めるとよくなる可能性はありますが, terraform で十分に柔軟なコード化が可能なので, terraform を選択してます。

細かいところ

プロトコルの管理について

ディレクトリとしてはこういう想定です。

  • go>schema: protoc generate したファイル置き場
  • schema: *.proto ファイル置き場
├── go
│   └── schema
└── schema

現状, golang からの利用しかないため, go>schema のみですが, ここには scala>schema だったり, python>schema だったり言語毎の自動生成ファイルも置けるよう考慮してる感じです。

protoc 自体は, バージョンによって生成される形が変わったりするので, なんらかの方法で protoc 差分が出ないようにする必要があります。

今回は, protoc の dockernize によってそちらを解決してます。

FROM golang:1.14

RUN apt-get update && \
    apt-get install unzip

RUN go get -u github.com/golang/protobuf/protoc-gen-go && \
    go get -u github.com/gogo/protobuf/proto && \
    go get -u github.com/gogo/protobuf/gogoproto && \
    go get -u github.com/gogo/protobuf/protoc-gen-gofast && \
    go get -u github.com/gogo/protobuf/protoc-gen-gogo && \
    go get -u github.com/gogo/protobuf/protoc-gen-gogofast && \
    go get -u github.com/gogo/protobuf/protoc-gen-gogofaster && \
    go get -u github.com/gogo/protobuf/protoc-gen-gogoslick && \
    go get -u github.com/pseudomuto/protoc-gen-doc/cmd/protoc-gen-doc && \
    go get -u google.golang.org/grpc/cmd/protoc-gen-go-grpc

RUN curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v3.13.0/protoc-3.13.0-linux-x86_64.zip && \
    unzip -o protoc-3.13.0-linux-x86_64.zip -d /usr/local bin/protoc && \
    unzip -o protoc-3.13.0-linux-x86_64.zip -d /usr/local include/* && \
    rm -rf protoc-3.13.0-linux-x86_64.zip

RUN mkdir -p /go/src/github.com/google && \
    git clone --branch master https://github.com/google/protobuf /go/src/github.com/google/protobuf && \
    git clone --branch master https://github.com/openconfig/gnmi /go/src/github.com/openconfig/gnmi && \
    mkdir -p /go/src/github.com/ &&\
    wget "https://github.com/grpc/grpc-web/releases/download/1.2.1/protoc-gen-grpc-web-1.2.1-linux-x86_64" --quiet && \
    mv protoc-gen-grpc-web-1.2.1-linux-x86_64 /usr/local/bin/protoc-gen-grpc-web && \
    chmod +x /usr/local/bin/protoc-gen-grpc-web

WORKDIR "/go/src/github.com/"

ENTRYPOINT ["protoc"]

後は, Makefile などにこのコンテナを走らせるようなスクリプトを書いておけばすぐ自動生成が行えて便利です。

protoc -I ./schema \
  --go_out=${go_out} --go_opt=paths=source_relative\
  --go-grpc_out=${go_out} --go-grpc_opt=paths=source_relative\
  ./schema/$$t/*.proto;\

地味にハマってしまった点なんですが, go_out, go-grpc_out 毎に souce_relative 指定がないと, よく分からない絶対パスで自動生成されるため注意が必要でした。

今回はやってなかった(今思いついた)んですが, エンジニアは proto ファイルさえ定義すれば, 自動生成ファイルのコミット自体は CD にしてしまうとかでも良さそうです。

アプリケーションレイヤー

サービスを複数立てるとき各々のサービスのレイヤー自体は異なっていても良いとは思うのですが, ある程度決めておいた方が後々楽そうなので決めてみました。

レイヤーの依存関係は以下で定義してみました。

  • adapter → usecase → domain ← infra

として, util はレイヤーと関係ないパッケージみたいなイメージです。
ディレクトリ構成としては以下の感じです。

├── adapter
├── build
├── config
├── di
├── domain
│   ├── entity
│   ├── repository
│   └── service
├── go.mod
├── go.sum
├── infra
│   ├── bridge
│   ├── psql
│   └── inmemory
├── main.go
├── test
│   └── e2e
├── usecase
└── util

マイクロサービス間の通信はどこのレイヤーだ...??みたいなのは修行中です。今回は, infra レイヤーの責務として infra>bridge で別サービスとの通信を実装してます。

domain repository にエンティティ操作用のインターフェースが定義されてて, infra 層で実装みたいな感じです。DIには wire ライブラリを利用してます。

gRPC における E2E テスト

サービス単位での E2E は, サービスをモックとして扱うという前提です。gRPC サーバーを立てる→client でサーバーを叩く。みたいな感じのテストコードになります。

こんな感じでテストコード時にサーバーが立つようにして

func init() {
	lis = bufconn.Listen(bufSize)
	s := grpc.NewServer()
	adapter := di.NewAdapter()
	pb.Register**Service(s, ***)
	go func() {
		if err := s.Serve(lis); err != nil {
			log.Fatalf("Server exited with error: %v", err)
		}
	}()
}

あとは単純に client 経由でサーバーを叩くだけです。
テストなので, Insecure 接続で良いと思います。

	ctx := context.Background()
	conn, err := grpc.DialContext(ctx, "bufnet", grpc.WithContextDialer(bufDialer), grpc.WithInsecure())
	if err != nil {
		t.Fatalf("Failed to dial bufnet: %v", err)
	}
	defer conn.Close()
	client := pb.New***Client(conn)

	name := "test name"
	resp, err := client.Create***(ctx, &pb.Request_Create***{Name: name})
	if err != nil {
		t.Fatalf("failed: %v", err)
	}

	assert.Equal(t, name, resp.Name)

client やリクエスト, レスポンスが自動生成されているのでテスタビリティが高くて良いです:thumbsup:

アプリケーション間の認証

クラウド上では, 基本的に未認証を許可してインターネットに公開することはせずに, 認証済みの通信だけ許可するようにします。

Gloud Run の場合は以下の通りで認証できます。
https://cloud.google.com/run/docs/authenticating/service-to-service?hl=ja

具体的にコードでいうとこんな感じで token とって Bearer に乗っける形になります。

	tokenSource, err := idtoken.NewTokenSource(ctx, config.***ServiceAudience())
	if err != nil {
		return fmt.Errorf("idtoken.NewTokenSource: %v", err)
	}
	token, err := tokenSource.Token()
	if err != nil {
		return fmt.Errorf("TokenSource.Token: %v", err)
	}
	ctx = grpcMetadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+token.AccessToken)

アプリケーションで proto 参照を行う

module 解決は replace で解決できます。

replace github.com/{autor}/{repo}/proto => ../../proto

モノレポの利点として, CI/CD 時もパス変わらないがあるので, CDするときだけ replace 外すみたいなことは必要なく便利です。

アプリケーションCI

PR+ディレクトリ単位で発火みたいなのは以下でいけます。repository をホームとしてそこから見た相対パスになります。

on:
  pull_request:
    branches:
      - dev
    paths:
      - services/foo-service/**

テストやビルドは golang に標準に備わってるのでコマンドを叩くだけです。

    - name: Build
      working-directory: services/foo-service
      run: go build -v .

    - name: Test
      working-directory: services/foo-service
      run: go test -v .

静的解析には, PR にコメントをくれる reviewdog が便利です。

    - uses: reviewdog/action-setup@v1
      with:
        reviewdog_version: latest

    - name: Setup golint
      run: go get -u golang.org/x/lint/golint

    - name: Review
      working-directory: services/foo-service
      run: reviewdog -conf=./.reviewdog.yml -reporter=github-pr-check
      env:
        REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}

アプリケーション dockernize

サービス毎で固有のミドルウェアを導入したいみたいな要件がない場合, 一つの Dockerfile を使い回しできます。

FROM golang:1.14 as build-stage

WORKDIR /work

COPY proto ../proto

ARG SERVICE
COPY services/${SERVICE}/go.mod go.mod
COPY services/${SERVICE}/go.sum go.sum
RUN go mod download

COPY services/${SERVICE}/. .
RUN CGO_ENABLED=0 GOOS=linux go build -o app

FROM debian:buster-slim
RUN set -x && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
    ca-certificates && \
    rm -rf /var/lib/apt/lists/*

COPY --from=build-stage /work/app /work/app

CMD [ "/work/app" ]

実際ビルドするときは以下みたいな makefile を用意しといて

build-foo-service:
  docker build \
    --tag=${IMAGE} \
    --file=./build/Dockerfile.api \
    --build-arg SERVICE="foo-service" \
    .

以下のようなステップを Actions に組み込む感じです。

...
  IMAGE: gcr.io/${{ secrets.GCP_PROJECT }}/foo-service:${{ github.sha }}
...
...
      - name: Build a docker image
        run: make build-foo-service IMAGE=${IMAGE}
...
...

クライアント→クラウドの認証

ローカルの場合, Insecure で大丈夫ですが, 本番(クラウド)に向けての通信となるとセキュアに行った方が良さそうです。

こんな感じで証明書付きで dial connection を生成して

creds, err = credentials.NewClientTLSFromFile("/etc/ssl/certs/ca-certificates.crt", "")
if err != nil {
	return nil, err
}
conn, err := grpc.Dial(addr, grpc.WithTransportCredentials(creds))

こんな感じでトークンを渡してあげることでいけます。許可された iam なら, $(gcloud auth print-identity-token) みたいな感じでアクセストークンが取得できます。

ctx, cancel := context.WithTimeout(ctx, time.Second)
	md := metadata.New(map[string]string{"Authorization": fmt.Sprintf("Bearer %s", token)})
	ctx = metadata.NewOutgoingContext(ctx, md)
	return ctx, func() {
		cancel()
	}

ちなみに IaM 認証ありなのは, dev などの想定で, prod になってくると bff については未認証を許可してあげる必要はありそうです。

ストレージ dockernize

ローカルでの開発効率を上げる点を考えるとローカルに dockernize されたストレージがあると便利です。(volume 消して再作成とかも簡単)

マイグレーションなど加味した最終的な docker-compose.yaml は以下です。

version: "3.7"
services:
  postgres:
    container_name: postgres
    image: postgres:13
    restart: always
    command: postgres -c log_destination=stderr -c log_statement=all -c log_connections=on -c log_disconnections=on
    logging:
      options:
        max-size: "10k"
        max-file: "5"
    ports:
      - 5000:5432
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./postgres/initdb.d:/docker-entrypoint-initdb.d
      - ./seeds:/seeds
    environment:
      - POSTGRES_USER
      - POSTGRES_PASSWORD
      - POSTGRES_DB
    networks:
      - backend

  wait:
    container_name: wait
    image: jwilder/dockerize
    command: ["dockerize", "-wait", "tcp://postgres:5432", "-timeout", "30s"]
    networks:
      - backend

  pgweb:
    container_name: pgweb
    restart: always
    image: sosedoff/pgweb
    ports:
      - 8081:8081
    environment:
       - DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
    networks:
      - backend

  migrate:
    build:
      context: build/
      dockerfile: Dockerfile.migrate
    command: ["up", "-env=${ENV}"]
    volumes:
      - ./migrations:/work/migrations
      - ./dbconfig.yml:/work/dbconfig.yml
    networks:
      - backend

  rollback:
    build:
      context: build/
      dockerfile: Dockerfile.migrate
    command: ["down", "-limit=1", "-env=${ENV}"]
    volumes:
      - ./migrations:/work/migrations
      - ./dbconfig.yml:/work/dbconfig.yml
    networks:
      - backend

  status:
    build:
      context: build/
      dockerfile: Dockerfile.migrate
    command: ["status", "-env=${ENV}"]
    volumes:
      - ./migrations:/work/migrations
      - ./dbconfig.yml:/work/dbconfig.yml
    networks:
      - backend

  generate:
    build:
      context: build/
      dockerfile: Dockerfile.migrate
    command: ["new", "-env=${ENV}"]
    volumes:
      - ./migrations:/work/migrations
      - ./dbconfig.yml:/work/dbconfig.yml
    networks:
      - backend

volumes:
  postgres_data:

networks:
  backend:

よく使うデータベースは mysql なんですが, mysql の docekrnize で若干苦労するのがログ部分で, postgres については標準出力が簡単なので良いです。

マイグレーションについては, golang で一番スターが多いのはなぜか golang-migrate/migrate ですが, こちらのマイグレーションツールは, 「マイグレーションファイル毎のステータスを見れない。」という明確なデメリットがあります。
rubenv/sql-migrate はその点クリアしているため, こちらを利用してます。

FROM golang:1.14
WORKDIR /work
RUN go get -v github.com/rubenv/sql-migrate/...
ENTRYPOINT [ "sql-migrate" ]

うまくいくとこんな感じになります。

❯ make st 
docker-compose -p grpgーdatabases run --rm status
Creating grpgdatabases_status_run ... done
+----------------------------------+--------------------------------------+
|            MIGRATION             |               APPLIED                |
+----------------------------------+--------------------------------------+
| 20201114070429-character.sql     | 2020-11-14 07:38:06.876282 +0000 UTC |
| 20201114071017-character_log.sql | 2020-11-14 07:38:06.883342 +0000 UTC |
+----------------------------------+--------------------------------------+

データベースのデプロイ

Cloud SQL など利用する場合, マイグレーションするとかになります。今回 GCE に dockernize したものを立てるので, databases ディレクトリを GCE に sync して, GCE に対して docker コマンドを叩かせる。みたいなことでデプロイを実現します。

コピーは gcloud compute scp から

run: |
    gcloud compute scp \
      databases appuser@dev-bastion-instance:~/ \
      --zone us-west1-a \
      --recurse --force-key-file-overwrite

recurse 指定でディレクトリをコピーになります。

コマンド叩くみたいなのは gcloud compute ssh --command から

run: |
    gcloud compute ssh \
        appuser@dev-bastion-instance \
        --zone us-west1-a \
        --command="cd databases; make up && make wait && make migrate;"

めちゃめちゃお手軽です。

インフラ terraform state ファイルを GS 管理する

terraform は state ファイルでコードとパブリッククラウドのリソースを紐付けてます。リモートに state ファイルがあることで分散管理に強くなります。

利用はめちゃめちゃ簡単で, bucket 名を指定するだけです。

terraform {
  backend "gcs" {
    bucket = "{bucket name}"
    prefix = "env/dev"
  }
}

インフラコードのモジュール化

環境毎に定義したいのは, 「こういう名前のVPC」「こういう条件のファイアーウォール」...などで, 実際リソースをどう定義するかはモジュールとして定義できます。

ちなみにモジュールについては, GCPが公式に作っているモジュールなど外部定義のモジュールも利用できます。

GCPが公式に使っているモジュールを利用したVPCモジュールは以下みたいな感じです。

module 側

module "vpc" {
  source  = "terraform-google-modules/network/google"
  version = "~> 2.5"

  project_id   = var.project
  network_name = var.env

  subnets = [
    {
      subnet_name   = "${var.env}-subnet-01"
      subnet_ip     = "10.${var.env == "dev" ? 10 : 20}.10.0/24"
      subnet_region = "us-west1"
    },
  ]

  secondary_ranges = {
    "${var.env}-subnet-01" = []
  }
}

environments 側

module "vpc" {
  source  = "../../modules/vpc"
  project = var.project
  env     = local.env
}

module は使い回しがきくのでうまく利用して保守性の高い IaC を心掛けたいですね。

インフラのCI

terraform にはファイルの静的解析を行ってくれる validate というコマンドと, 反映せず内容だけ確認する plan というものがあります。

また, terraform 公式に GitHub Actions を用意してくれているので以下のような記述のみで済みます。

      - name: tf validate
        uses: hashicorp/terraform-github-actions@master
        with:
          tf_actions_version: 0.12.29
          tf_actions_subcommand: 'validate'
          tf_actions_working_dir: 'deployments/environments/dev'
          tf_actions_comment: true
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

tf_actions_comment で一点注意が必要なのが, GitHub Actions 上では秘匿してくれている情報も comment として PR に乗ったりしちゃうとそのまま出力されてしまう。という点です。公開 repository で上記を扱う場合注意が必要。

インフラのCD

上記の actions を利用して apply コマンドで実際反映できます。
秘匿したい情報については, 簡易だと Actions 上でシークレットを渡す形で生成できます。
ちゃんとやるなら KMS とか使った方が良いのかもしれません。

      - name: Generate tfvars
        run: |
          cat <<EOF > deployments/environments/dev/terraform.tfvars
          project = "${{ secrets.GCP_PROJECT }}"
          allow_ip = "${{ secrets.ALLOW_IP }}"
          EOF

最後に

サーバーアプリケーション構築の上で必要最低限必要になるモダンな環境というところで極力コストを抑えて用意というところで, 以上となります!いかがでしたでしょうか?

個人的には割と全体的には満足してるところはありつつ, CD周りもうちょっと改善できそうだなぁとか色々と考えてます。

ぜひこれを見た上で, 「こここれに切り替えると良いよ。」とか「このツールも便利。」とかとかあったら教えて欲しいです!

モダンと呼ばれる環境が必ずしもプロダクトとマッチするかというとそうではないですが, キャッチアップを続けて最適なアーキテクトを提供できるようになりたいですね。

11
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?