Help us understand the problem. What is going on with this article?

Goで作成したAPIサーバをdockerで複数コンテナ運用

初めに

先日まで、アーキテクチャを意識したAPI開発を行っていました。API開発がひと段落し、docker-composeでAPIサーバ,
nginxとMySQLをdocker上でつなげたいと思い、docker,nginxについて勉強をしました。成果物としては、まだ途中なのですがひと段落したので記事を書きました。

対象読者

  • これからdockerについて学んでいくよという方
  • golangを使ってAPI開発を行ったが次何やればいいかわからない方
  • docker-compose.ymlの書き方がわからない方
  • nginx設定ファイルの書き方がわからない方

目標物

成果物.png

APIサーバについて

アーキテクチャ

アーキテクチャはレイヤードアーキテクチャを採用した。エラーハンドリングやログ出力もしっかりと構築して行きたかったため、採用した。
アーキテクチャについては「今すぐ「レイヤードアーキテクチャ+DDD」を理解しよう。(golang)」を参考にした。

$ tree --dirsfirst
.
├── config
│   ├── nginx
│   │   ├── Dockerfile
│   │   └── default.conf
│   └── config.go
├── domain
│   ├── model
│   │   └── user.go
│   └── repository
│       └── user.go
├── handler
│   ├── rest
│   │   └── user.go
│   └── logging.go
├── infra
│   └── persistence
│       ├── errors.go
│       └── user.go
├── usecase
│   └── user.go
├── Dockerfile
├── README.md
├── books.log
├── configs.ini
├── docker-compose.yml
├── fresh.conf
├── go.mod
├── go.sum
├── main
└── main.go

10 directories, 20 files

仕様

今回作ったAPIサーバーはGo言語で作成した。仕様はuserのSignUp・SignIn・ユーザー一覧表示・ユーザー検索です。
ポート9000を開放しリクエストを待ち受けます。

package main

import (
    "log"
    "net/http"
    "github.com/julienschmidt/httprouter"
    "github.com/tnkyk/clean_book_go/config"
    logging "github.com/tnkyk/clean_book_go/handler"
    "github.com/tnkyk/clean_book_go/handler/rest"
    "github.com/tnkyk/clean_book_go/infra/persistence"
    "github.com/tnkyk/clean_book_go/usecase"
)

func main() {
        //指定したLogFileにlogを出力するための設定
    logging.LoggingSetting(config.Config.LogFile)

    userPersistence := persistence.NewUserPersistence()
    userUseCase := usecase.NewUserUsecase(userPersistence)
    userHandler := rest.NewUserHandler(userUseCase)

        //httprouterを用いてハンドラーを登録する
    Router := httprouter.New()
    Router.GET("/api/users", userHandler.Index)
    Router.POST("/api/signup", userHandler.SignUp)
    Router.POST("/api/signin", userHandler.SignIn)
        //ポート9000で待ち受け
    err := http.ListenAndServe(":9000", Router)
    if err != nil {
        log.Fatalf("Listen and serve failed. %+v", err)
    }
        log.Println("Server running...")
}

SignUp

ユーザーが新規登録を行うためのAPI。入力name,email,passwordを受けUserIdを生成し、passwordはハッシュ化し必要情報をデータベースに格納する。

SignIn

ユーザーのログインAPIである。入力name,email,passwordを受け、nameを元にデータベースから一致するuser情報を取得し、取得したuser情報のpasswordと入力のpassowordが一致しているかどうかを確認する。認証が確認したら、JWTを用いてトークンの生成を行い、生成したトークンをレスポンスする。

ユーザー一覧取得

ユーザーの一覧表示のAPIである。

今回初めて触れたこと

JWT

JWTとはJSON Web Token の略で、電子署名付きの URL-safe(URLとして利用出来る文字だけ構成される)な JSONのことです。

  • 用途:JWTは認証とデータの送信の際に使います。
  • 構成要素
    • header:ヘッダーは2つの部分で構成されている。トークンのタイプと、使用されている署名アルゴリズム(HMAC SHA256やRSAなど)の記述。
    • payload:ユーザーと追加データに関する記述。
    • verify signature:エンコードされたヘッダー、エンコードされたペイロード、シークレット、ヘッダーで指定されたアルゴリズムを取得し、署名する記述。
header
{
  "alg": "HS256",
  "typ": "JWT"
}
payload
{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}
signature
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  your-256-bit-secret
)

今回JWTを認証に用いいる。そのため、JWTがどのように動き認証を行うかを示す。
JWTはログインAPIを叩いたuserのトークンをサーバー側で作り、そのトークンをbodyに追加しレスポンスとして返す。クライアント側ではLocalStrageにレスポンスBodyから抽出したトークンを保存する。次からそのユーザーがAPIを叩く時トークンをサーバー側に送る。サーバー側では送られてきたトークンをトークン作成時に仕様した同じアルゴリズムでデコードを行う。この際に改竄されていると正しくデータを抽出することができないため、改竄されているかどうかは一目瞭然である。デコードを行った後、payloadに入っているidやトークンの有効期限を読み取り、正しい場合叩かれたAPIの処理を行う。詳しくは、「Go言語で理解するJWT認証 実装ハンズオン」がわかりやすかったので見ていただきたいです。

nginxについて

nginxとは

Webサーバーのソフトウェアです。Apacheが有名ですが、近年nginxが人気になってきたっぽい。
特徴として、
・リバースプロキシー
・負荷分散、
・URL Rewrite
・WebDAV(webファイル共有)
があげられる。

default.confの書き方

今回作成したnginxの設定ファイルをコードの説明とともに以下に記します。

server {
   #ポート80を開放
    listen 80;
    #サーバの名前を決める
    server_name localhost;
    #指定のロケーション(パスやファイル)の場合に適用。
    location /api/ {
        ## docker-compose.ymlのserviceでapiサーバーをwebとしている
        ## 9000のポートを開いている
        proxy_pass http://web:9000;
    }

    error_page   500 502 503 504  /50x.html;
    エラーステータスに応じたHTMLを表示
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}

詳しくはNginx設定のまとめがとてもわかりやすく詳細に説明してくださっているので是非読んでみるといいと思います。

dockerについて

dockerとは

Dockerはコンテナと呼ばれる仮想化のための技術。dockerが出てくる前までは、どんなに同じライブラリやフレームワークなどを使ってもOSの違いからエラーが出ることが多かった。しかし、dockerはコンテナと呼ばれる仮想環境にimage(OSなどの元となるファイル)を読み込み、そのimage上で自分が開発したソフトウェアが動くため、ホストOS(自分のPCのOS)が何であってもコンテナさえしっかりと動く状態であれば、そのコンテナを他の環境でも動かすことができる。以下の図は入門Dockerから引用しました。
docker.png

Dockerfile

DockerfileとはDockerコンテナーの構成内容をまとめて記述するシンプルなテキスト形式のファイルのこと。
以下の表はDockerfile内で使用されるコマンド一覧です。

コマンド 意味
FROM 元となるDockerイメージの指定
MAINTAINER 作成者の情報
RUN コマンドの実行
CMD コンテナの実行コマンド
ADD ファイルやディレクトリの追加
ENTRYPOINT コンテナの実行コマンド
WORKDIR 作業ディレクトリの指定
ENV 環境変数の設定
USER 実行ユーザーの指定
EXPOSE ポートのエクスポート
VOLUME カレントディレクトリからコンテナ内のディレクトリへのマウント

今回の成果物のDockerfile(nginx用、APIサーバー用)は以下のようになります。

./config/nginx/Dockerfile
#nginxの最新バージョンのimageを指定
FROM nginx:latest
#カレントディレクトリにあるdefault.confというファイルを/etc/nginx/conf.d/default.confへコピー
COPY ./default.conf /etc/nginx/conf.d/default.conf
./Dockerfile
#golangの最新バージョンのimageを指定
FROM golang:latest
#作成者情報を記入
MAINTAINER user <user@example.com>

# 作業用ディレクトリを指定
WORKDIR /go/src/github.com/tnkyk/clean_book_go

# カレントディレクトリの全てのファイルやディレクトリをコンテナのrootディレクトリにコピー
COPY . .

# APIサーバーで用いたライブラリを取得
 RUN go get github.com/davecgh/go-spew 
 RUN go get github.com/dgrijalva/jwt-go 
 RUN go get github.com/go-sql-driver/mysql 
 RUN go get github.com/google/uuid 
 RUN go get github.com/julienschmidt/httprouter 
 RUN go get github.com/tnkyk/LayeredArch_sample 
 RUN go get golang.org/x/crypto 
 RUN go get google.golang.org/appengine 
 RUN go get gopkg.in/go-ini/ini.v1 
 RUN go get gopkg.in/ini.v1 

# バイナリーファイルを取得 
RUN go install github.com/tnkyk/clean_book_go/cmd/api
# Linux用バイナリファイルをgo build して取得
RUN  GOOS=linux GOARCH=amd64 go build ./cmd/api/main.go

# ポートの指定
EXPOSE 9000 
#バイナリファイルの実行
CMD [ "./main" ]

docker-composeとは

複数のコンテナからなるサービスを構築・実行する手順を自動的にし、管理を容易にする機能のこと。

docker-compose.ymlの書き方

docker-composeはyamlファイルで記述される。今回作成したdocker-compose.ymlは以下の通りである。ymlファイルの説明は以下のコードのコメントアウトに記述してある。

version: "3"

services:
  db:
    image: mysql:5.7.22 #ベースイメージの設定 imageかbuildどちらかは必須
    restart: always #コンテナ起動時に自動起動する設定。自動起動させたくない場合はこの記述を削除すれば良い
    container_name: myapp-db #コンテナの名前を決める、あってもなくても良い
    ports:
      - 3306:3306 #どのポートを開放するかを設定":"の左側はホストのポート、右側はコンテナのポート
    volumes: # ./mysqlと言うローカルディレクトりをコンテナの指定ディレクトリにマウント
      - ./mysql:/docker-entrypoint-initdb.d
    #environment: #環境変数を指定する場合はこのように記述する。
      # MYSQL_ROOT_USERNAME: root
      # MYSQL_ROOT_PASSWORD: root
      # MYSQL_DATABASE: database
      # MYSQL_USER:     root
      # MYSQL_PASSWORD: root
      # TZ: Asia/Tokyo
  web:
    build: #dockerfileの指定
      context: . #ディレクトリの指定
      dockerfile: Dockerfile #ファイル指定
    container_name: myapp-go
    volumes: 
      - ./:/go/src/github.com/tnkyk/clean_book_go
    ports:
      - 9000:9000
    depends_on: #DBに依存している APIサーバがDBにアクセスする。アクセス方向にdepends_onを指定
      - db
  proxy:
    build: ./config/nginx
    container_name: myapp-nginx
    ports:
      - 8000:80
    depends_on:
      - web

上記のコード中にあるdb,web,redis,proxyは作成者がつけた適当なものである。docker-compose.ymlを書くにあたって必要なこととして、『成果物のアーキテクチャが明確化していること』があげられる。
 dbのvolumeについて。指定したディレクトリ(今回だと./mysql)にデータベースのを作るためのファイル(xxx.sql)を入れることで、データベースの情報をコンテナに運ぶことができ、コンテナ起動時に自動的にCREATE DATABASEできデータも INSERTできる。 このことを知らずに、volumeを指定せずに起動したところ、データベースがありませんというerrorが出て躓いたので気をつけてください。

上記のDockerfileの改善点

このDockerfileではコンテナ化した後、ファイルに変更を加える度に手動でbuildをしなければならない状態である。そこでGo言語の外部ライブラリである,freshを用いる。

freshとは

コンテナ化したAPIサーバをホットリロード開発環境にするライブラリ。ホットリロードとは,「ファイルの変更をトリガにリアルタイムで変更を反映してくれる」と言う意味です。「ファイルを変更→docker build」と言う作業をfreshと言うライブラリを導入することでファイルを変更すれば、変更を検知して自動的に行ってくれるようにした。毎回ターミナルでdocker buildをする作業が減ったことで、効率化を図れる。また、dockerfileの記述も少なくて済むようになる。

変更後のDockerfile

変更後のDockerfileは以下のようになる。コメントアウトされた分記述量が減る。

FROM golang:latest
MAINTAINER user<user@example.com>
# Copy the local package files to the container’s workspace.

WORKDIR /go/src/github.com/tnkyk/clean_book_go

COPY . .

#set variable for HotReload
ENV GO111MODULE=on


# Install our dependencies
# RUN go get github.com/davecgh/go-spew 
# RUN go get github.com/dgrijalva/jwt-go 
# RUN go get github.com/go-sql-driver/mysql 
# RUN go get github.com/google/uuid 
# RUN go get github.com/julienschmidt/httprouter 
# RUN go get github.com/tnkyk/LayeredArch_sample 
# RUN go get golang.org/x/crypto 
# RUN go get google.golang.org/appengine 
# RUN go get gopkg.in/go-ini/ini.v1 
# RUN go get gopkg.in/ini.v1 

#Only get this library instead of these library
RUN go get github.com/pilu/fresh 

# Install api binary globally within container 
# RUN go install github.com/tnkyk/clean_book_go/cmd/api

#RUN  GOOS=linux GOARCH=amd64 go build ./cmd/api/main.go

# Expose default port (3306)
EXPOSE 9000 

WORKDIR /go/src/github.com/tnkyk/clean_book_go
# CMD [ "./main" ]
CMD ["fresh"]

実際にホットリロードしているか確認する。

step1.ビルド

$ docker-compose build
db uses an image, skipping
redis uses an image, skipping
Building web
Step 1/9 : FROM golang:latest
 ---> a2e245db8bd3
Step 2/9 : MAINTAINER tnkyk<yuukiyuuki327@gmail.com>
 ---> Using cache
 ---> 22828b6502a1
Step 3/9 : WORKDIR /go/src/github.com/tnkyk/clean_book_go
 ---> Using cache
 ---> cb98729d3b27
Step 4/9 : COPY . .
 ---> Using cache
 ---> cbb188a23e4a
Step 5/9 : ENV GO111MODULE=on
 ---> Using cache
 ---> 99325f7525e9
Step 6/9 : RUN go get github.com/pilu/fresh
 ---> Using cache
 ---> 38a2e2d9c7ef
Step 7/9 : EXPOSE 9000
 ---> Using cache
 ---> 41259f0a5f12
Step 8/9 : WORKDIR /go/src/github.com/tnkyk/clean_book_go
 ---> Using cache
 ---> 02a5409a99da
Step 9/9 : CMD ["fresh"]
 ---> Using cache
 ---> 884f211c77cc
Successfully built 884f211c77cc
Successfully tagged clean_book_go_web:latest
Building proxy
Step 1/2 : FROM nginx:latest
 ---> 231d40e811cd
Step 2/2 : COPY ./default.conf /etc/nginx/conf.d/default.conf
 ---> Using cache
 ---> ddd091cbb462
Successfully built ddd091cbb462
Successfully tagged clean_book_go_proxy:latest

step2.コンテナの開始

$ docker-compose up -d
myapp-db is done
myapp-go is done
myapp-nginx is done

step3.コンテナのログを見る

$docker-compose logs -f
:
:
myapp-go | 10:17:10 main        | Waiting (loop 1)...
myapp-go | 10:17:10 main        | receiving first event /
myapp-go | 10:17:10 main        | sleeping for 600 milliseconds
myapp-go | 10:17:10 main        | flushing events
myapp-go | 10:17:10 main        | Started! (38 Goroutines)
myapp-go | 10:17:10 main        | remove tmp/runner-build-errors.log: no such file or directory
myapp-go | 10:17:10 build       | Building...
myapp-go | 10:17:12 runner      | Running...
myapp-go | 10:17:12 main        | --------------------
myapp-go | 10:17:12 main        | Waiting (loop 2)...

ログ解析をしたままファイルに変更を加えてみると

myapp-go | 10:25:57 watcher     | sending event "handler/rest/user.go": MODIFY|ATTRIB
myapp-go | 10:25:57 main        | receiving first event "handler/rest/user.go": MODIFY|ATTRIB
myapp-go | 10:25:57 main        | sleeping for 600 milliseconds
myapp-go | 10:25:57 watcher     | sending event "handler/rest/user.go": MODIFY
myapp-go | 10:25:58 main        | flushing events
myapp-go | 10:25:58 main        | receiving event "handler/rest/user.go": MODIFY
myapp-go | 10:25:58 main        | Started! (41 Goroutines)
myapp-go | 10:25:58 main        | remove tmp/runner-build-errors.log: no such file or directory
myapp-go | 10:25:58 build       | Building...
myapp-go | 10:25:59 runner      | Running...

というように自動ビルドされることがわかる。とても便利!!!

リクエストを送ってみる

curl
$ curl http://localhost:8000/api/users
{"users":[{"id":"6494210a-51c7-4069-a938-594b56e87665","name":"name2","email":"email2","created_at":"2019-12-03T06:42:56Z"},{"id":"6c129ef9-c20e-439d-a9c4-bbdc0b694299","name":"name1","email":"email1","created_at":"2019-12-03T06:42:41Z"}]}

ログ出力

docker-compose-log
myapp-nginx | 172.28.0.1 - - [06/Dec/2019:11:22:42 +0000] "GET /api/users HTTP/1.1" 200 354 "-" "curl/7.64.1" "-"

確かに、目標物の仕様にのっとっていることがわかった。

躓いた点

・nginxの設定ファイルの書き方
・docker-composeの書き方
・コンテナを作りそのコンテナはdefaultでipアドレスが振り分けられること(違うipアドレスにアクセスしていてエラーが出て躓いた)
・docker-composeのvolumeの理解(コンテナとDBとの関係)

参考文献

Qiita-Go v1.11 + Docker + fresh でホットリロード開発環境を作って愉快なGo言語生活
今すぐ「レイヤードアーキテクチャ+DDD」を理解しよう。(golang)
JWT-公式ドキュメント
JWT について調べた内容をまとめました。
「Go言語で理解するJWT認証 実装ハンズオン」
Docker MySQLコンテナ起動時に初期データを投入する

最後に

今回は自作したAPIサーバをnginxとmysqlに繋ぎ、コンテナ化、ホットリロード化しました。次にやって行きたいこととしては、AWSにDockerの環境を構築し今回作ったコンテナを乗せることです。最後まで記事を見ていただきありがとうございました。何か理解が誤っている点などありましたら、ご教授いただけると幸いです。
今回作成した物は「こちら」にあります。

謝辞

今回、TechTrainのメンターの方に質問をさせていただきdocker-composeについてやホットリロードについてなどを理解することができました。ありがとうございました。

追記

以下のbuildの部分を変更
mac環境において

build:  #dockerfileの指定 <------これだけに変更
      context: .    #ディレクトリの指定 <-------------/Users/username/go/src/myapp/
      dockerfile: Dockerfile    #ファイル指定 <-----------/Users/username/go/src/myapp/Dockerfile

としたとき(共同開発のため、/go/src/においた)

$ docker-compose build
ERROR: build path /Users/username/go/src/myapp/build/.  either does not exist, is not accessible, or is not a valid URL.

というエラーがでた。このエラーを直すため、以下のように変更した。

docker-compose.yml
version: "3"

services:
  db:
    image: mysql:5.7.22 #ベースイメージの設定 imageかbuildどちらかは必須
    restart: always #コンテナ起動時に自動起動する設定。自動起動させたくない場合はこの記述を削除すれば良い
    container_name: myapp-db #コンテナの名前を決める、あってもなくても良い
    ports:
      - 3306:3306 #どのポートを開放するかを設定":"の左側はホストのポート、右側はコンテナのポート
    volumes: # ./mysqlと言うローカルディレクトりをコンテナの指定ディレクトリにマウント
      - ./mysql:/docker-entrypoint-initdb.d
    #environment: #環境変数を指定する場合はこのように記述する。
      # MYSQL_ROOT_USERNAME: root
      # MYSQL_ROOT_PASSWORD: root
      # MYSQL_DATABASE: database
      # MYSQL_USER:     root
      # MYSQL_PASSWORD: root
      # TZ: Asia/Tokyo
  web:
    build: . #dockerfileの指定 <------これだけに変更
      context: . #ディレクトリの指定 <-------------削除
      dockerfile: Dockerfile #ファイル指定 <ーーーーーーーーー削除
    container_name: myapp-go
    volumes: 
      - ./:/go/src/github.com/tnkyk/clean_book_go
    ports:
      - 9000:9000
    depends_on: #DBに依存している APIサーバがDBにアクセスする。アクセス方向にdepends_onを指定
      - db
  proxy:
    build: ./config/nginx
    container_name: myapp-nginx
    ports:
      - 8000:80
    depends_on:
      - web

yuukiyuuki327
学生です。アウトプットを行うために、Qiitaを利用しようと思っております。説明不足な点あるかと思いますが、よろしくお願いします。
techtrain
プロのエンジニアを目指すU30(30歳以下)の方に現役エンジニアにメンタリングもらえるコミュニティです。
https://techbowl.co.jp/techtrain/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした