Go,Nuxt,MySQLの環境をGCPにデプロイしてみた

開発環境から本番環境まで構築し、Google Cloud Platform(GCP)にデプロイしてみました。

環境

  • フロントエンド:Nuxt.js (Firebase Hosting)
  • バックエンド:Go (Cloud Run)
  • DB:MySQL (Cloud SQL)

()内は使用するGCPのサービスです。

ブラウザからユーザーの氏名を送信するとDBに登録され、画面に全ユーザーの氏名を表示するだけの単純なアプリケーションを作成し、GCPにデプロイしました。最終的なソースは以下です。

GitHub - yutakahashi114/go_nuxt_mysql
Contribute to yutakahashi114/go_nuxt_mysql development by creating an account on GitHub.

ディレクトリ構成はこんな感じです。

.  
├── client/  
│   └── var/  
│       └── www/  
│           └── project/  <- Nuxtのプロジェクト  
├── go/  
│   ├── .env  
│   ├── Dockerfile  
│   └── src/  
│       └── app/          <- Goのサーバーのコード  
├── mysql/  
│   ├── data/  
│   ├── init/  
│   └── my.cnf  
└── docker-compose.yml

docker-compose.ymlを書きます。

version: '3.7'
services:
go:
build:
context: ./go/
target: dev
volumes:
- ./go/src:/go/src:cached
working_dir: /go/src/app
ports:
- 8080:8080
tty: true
env_file:
- ./go/.env
mysql:
image: mysql:latest
volumes:
- ./mysql/data:/var/lib/mysql
- ./mysql/my.cnf:/etc/mysql/conf.d/my.cnf
- ./mysql/init:/docker-entrypoint-initdb.d
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: database
MYSQL_USER: user
MYSQL_PASSWORD: password
TZ: 'Asia/Tokyo'
node:
image: node:latest
ports:
- 3000:3000
volumes:
- ./client/var/www:/var/www:cached
working_dir: /var/www/project
tty: true
depends_on:
- go
- mysql

GoのDockerfileです。

FROM golang:latest as dev
RUN apt-get update \
&& apt-get install -y vim \
&& curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh

DB

以下の記事を参考に設定ファイルを作成しました。

docker-compose でMySQL環境簡単構築 - Qiita
はじめに docker-composeを使って、MySQL環境を構築します。サンプルとしてtestテーブル作成〜phpMyAdminで確認できるまでの手順をご紹介します。なるべく、シンプルにという事で、MySQLとphpMyAd...
[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
[client]
default-character-set=utf8mb4
view raw go-nuxt-mysql-mycnf hosted with ❤ by GitHub

以下のファイルを作成し、マウントしておくと、DBの初回起動時にusersテーブルが自動で作成されます。

create table IF not exists `users`
(
`id` INT(20) AUTO_INCREMENT,
`first_name` VARCHAR(20) NOT NULL,
`last_name` VARCHAR(20) NOT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

バックエンド

以下の記事を参考に、GoでAPIサーバーを実装しました。

Clean ArchitectureでAPI Serverを構築してみる - Qiita
この記事では、アーキテクチャを採用する理由、次にClean Architectureの概要、最後にアプリケーションの構築をしていきます。 この後詳しく見ていきますが、Clean architectureの概念は比較的シンプルでわかり...

基本的に上の記事と同じものを書きましたが、APIのパスを少し変えています。

api := router.Group("/api")  
{  
    api.POST("/users", ...)  
    api.GET("/users/:id", ...)  
    api.GET("/users", ...)  
}  
Router = router

DBへの接続部分は、以下のように環境変数でセットするようにしました。

user := os.Getenv("DB_USER")  
pass := os.Getenv("DB_PASSWORD")  
host := os.Getenv("DB_HOST")  
name := os.Getenv("DB_DATABASE")  
sock := os.Getenv("DB_SOCKET")  
dns := fmt.Sprintf("%s:%s@%s(%s)/%s", user, pass, sock, host, name)  
conn, err := sql.Open("mysql", dns)

DBの設定に合わせて .envを作成します。

DB_USER=user
DB_PASSWORD=password
DB_HOST=mysql:3306
DB_DATABASE=database
DB_SOCKET=tcp
view raw go-nuxt-mysql-env hosted with ❤ by GitHub

Goのコンテナに入り、go run server.goでサーバー立ち上げると、以下のエンドポイントができます。

POST   /api/users  
GET    /api/users/:id  
GET    /api/users

フロントエンド

Nuxtのプロジェクトを作成し、氏名の入力フォームとユーザー情報を表示するページを作成します。

<template>
<div>
<input v-model="lastName" placeholder="姓">
<input v-model="firstName" placeholder="名">
<button @click="addUserFromForm" class="button">登録</button>
<div class="table">
<div class="table-row">
<div class="table-column table-column-1">ID</div><div class="table-column table-column-2">名前</div>
</div>
<template v-for="user in users">
<div :key="user.ID" class="table-row">
<div class="table-column table-column-1">{{user.ID}}</div><div class="table-column table-column-2">{{user.LastName}} {{user.FirstName}}</div>
</div>
</template>
</div>
</div>
</template>
<script>
import axios from 'axios';
import { mapState, mapActions } from 'vuex';
export default {
created() {
this.getAllUsers()
},
data() {
return {
lastName: "",
firstName: "",
}
},
computed: mapState({
users: 'users'
}),
methods: {
...mapActions([
'getAllUsers',
'addUser',
]),
async addUserFromForm() {
await this.addUser({
firstName: this.firstName,
lastName: this.lastName,
})
this.firstName = "";
this.lastName = "";
this.getAllUsers()
},
}
}
</script>
<style lang="scss">
.table {
margin: 10px;
.table-row {
display: flex;
align-items: center;
height: 30px;
}
.table-column {
text-align: center;
&.table-column-1 {
width: 50px;
}
&.table-column-2 {
width: 150px;
}
}
}
.button {
margin: 10px;
}
</style>
import axios from 'axios';
export const state = () => ({
users: []
})
export const mutations = {
setUsers(state, users) {
state.users = users;
}
}
export const actions = {
async getAllUsers(context) {
try {
const { data } = await axios.get('/api/users')
context.commit('setUsers', data)
} catch (error) {
console.log(error)
}
},
async addUser(context, params) {
try {
await axios.post('/api/users', params)
} catch (error) {
console.log(error)
}
}
}

package.jsonです。 docker内で起動するので、scripts.devを HOST=0.0.0.0 PORT=3000 nuxt としています。

Firebaseにデプロイするので、そのためのツールも追加しています。

{
"name": "project",
"version": "1.0.0",
"description": "My kickass Nuxt.js project",
"author": "",
"private": true,
"scripts": {
"dev": "HOST=0.0.0.0 PORT=3000 nuxt",
"build": "nuxt build",
"start": "nuxt start",
"generate": "nuxt generate"
},
"dependencies": {
"@nuxtjs/axios": "^5.3.6",
"firebase": "^6.6.1",
"firebase-tools": "^7.3.2",
"nuxt": "^2.0.0",
"ress": "^1.2.2"
},
"devDependencies": {
"node-sass": "^4.12.0",
"sass-loader": "^8.0.0"
}
}

nuxt.config.js のproxy部分の設定を以下のようにします。

axios: {  
  proxy: true  
},  
proxy: {  
  '/api': 'http://go:8080'  
},

Nodeコンテナに入り、yarn run devを実行すると、 localhost:3000にアクセスできます。

ここまで終わったらdocker-composeを少し修正しました。

version: '3.7'
services:
go:
build:
context: ./go/
target: dev
volumes:
- ./go/src:/go/src:cached
working_dir: /go/src/app
tty: true
env_file:
- ./go/.env
mysql:
image: mysql:latest
volumes:
- ./mysql/data:/var/lib/mysql
- ./mysql/my.cnf:/etc/mysql/conf.d/my.cnf
- ./mysql/init:/docker-entrypoint-initdb.d
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: database
MYSQL_USER: user
MYSQL_PASSWORD: password
TZ: 'Asia/Tokyo'
node:
image: node:latest
ports:
- 3000:3000
volumes:
- ./client/var/www:/var/www:cached
working_dir: /var/www/project
command: ["yarn", "run", "dev"]
depends_on:
- go
- mysql

goのportsとnodeのttyを削除し、nodeのcommandを追加しました。

Cloud Run

デプロイ用のDockerfileを作成します。開発用のDockerfileに追記しましたが、分けて書いても良いと思います。

# 開発
FROM golang:latest as dev
RUN apt-get update \
&& apt-get install -y vim \
&& curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh
# 本番ビルド用
FROM golang:latest as builder
WORKDIR /go/src/app/
COPY ./src/app .
RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build server.go
# 本番
FROM alpine:latest as prod
EXPOSE 8080
WORKDIR /api
COPY --from=builder /go/src/app/server .
CMD ["./server"]

buildし、Container Registryにpushします。

docker build -t us.gcr.io/go-nuxt-mysql-b35d9/go-nuxt-mysql --target prod ./go/  
docker push us.gcr.io/go-nuxt-mysql-b35d9/go-nuxt-mysql

GCPのコンソールでCloud Runのサービスを新規作成します。

DB_HOSTは /cloudsql/<project-id>:<region>:<instance-id> です。

/cloudsql/以下は「Cloud SQL 接続」で表示されている文字列をそのまま設定すればOKです。詳しくは公式ドキュメントを参照下さい。

Cloud Functions から Cloud SQL への接続 | Cloud SQL for MySQL | Google Cloud

Firebase Hosting

ローカルでNodeのコンテナに入り、

yarn firebase login --no-localhost

でFirebaseにログインします。Firebaseのコマンドにyarnを毎回つけるのが地味に面倒なので、Dockerfileを書いてグローバルに追加する等した方が良いかもしれません。

yarn firebase init

でプロジェクトを作成し、適当に設定します。デプロイするディレクトリは dist としておきます。

自動で作成されたfirebase.jsonを、以下のように修正します。

{
"hosting": {
"public": "dist",
"ignore": [
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"headers": [
{
"source": "/api/**",
"headers": [
{
"key": "Cache-Control",
"value": "no-cache, no-store, must-revalidate"
}
]
}
],
"rewrites": [
{
"source": "/api/**",
"run": {
"serviceId": "go-nuxt-mysql",
"region": "us-central1"
}
},
{
"source": "**",
"destination": "/index.html"
}
]
}
}

これで /api/...へのアクセスが全てCloud Runにリダイレクトされます。詳しくは公式ドキュメントを参照下さい。

Cloud Run を使用した動的コンテンツの配信とマイクロサービスのホスティング | Firebase

yarn build でデプロイ用のファイルを作成します。

最後に

yarn firebase deployでデプロイします。

発行されたURLにアクセスすると、ページが表示されます。

以上で全ての設定が完了しました。

セキュリティ関連を何も気にせず作成したので、その辺はもう少し勉強しようと思います。

ハマったところ

備忘録も兼ねて、主に本番環境にデプロイする時にハマった箇所についていくつか挙げておきます。

Cloud SQL への接続情報

・tcpでなくunix

・ポート番号はセットしない

最初、ローカルと同じノリで接続情報を以下のように設定していました。

user:password@tcp(/cloudsql/go-nuxt-mysql-b35d9:us-central1:go-nuxt-mysql:3306)/database

が、何度試しても接続できず。

正しくは以下になります。

user:password@unix(/cloudsql/go-nuxt-mysql-b35d9:us-central1:go-nuxt-mysql)/database

公式ドキュメントはきちんと読みましょう!

Goのクロスコンパイル

・alpine用のビルドにはGOOS=linux GOARCH=amd64 CGO_ENABLED=0

今回、軽量なイメージを作るためにmulti stage buildを使用し、デプロイ用のbaseイメージにはalpineを使用しました。最初はGoのビルドに以下のコマンドを実行していました。

GOOS=linux GOARCH=amd64 go build server.go

が、これでCloud Runにデプロイしても動きませんでした。alpineイメージで動かすためには設定が足りなかったようです。

alpineイメージにgo build済みのバイナリを載せて実行 | infraya.work
概要 alpineイメージにgo build済みのシングルバイナリだけを載せて動かす結論 alpineで動かすには…go build時に CGO_ENABLED=0オプションをつけて、cgo を無効にする必要がある1 > GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build main.go 目的 alpineイメージにGoのシングルバイナリ一発構成をやってみたかったTCPサーバを作る Goを始めたばかりなので参考サイト見ながら簡素な作りなもの作った1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 1…
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build server.go

これで動くようになりました。

Firebaseのリダイレクト設定

・上から順に判定

初めはfirebase.jsonに以下のようなリダイレクトの設定を書いていました。

"rewrites": [  
    {  
        "source": "**",  
        "destination": "/index.html"  
    },  
    {  
        "source": "/api/**",  
        "run": {  
            "serviceId": "go-nuxt-mysql",  
            "region": "us-central1"  
        }  
    }  
]

今思うと当然ですが、これだと全てのリクエストが"**"に引っかかり、Cloud Runにリダイレクトされません。ステータスは200で返ってくるのに正常に取得できず、地味に混乱しました。

以下のように逆に書きます。

"rewrites": [  
    {  
        "source": "/api/**",  
        "run": {  
            "serviceId": "go-nuxt-mysql",  
            "region": "us-central1"  
        }  
    },  
    {  
        "source": "**",  
        "destination": "/index.html"  
    }  
]

Firebaseのキャッシュ

・リクエストヘッダに Cache-Controlを追加する

DBのデータは更新しているのに、画面に表示する情報が全く変わりませんでした。Cloud Runへ直接アクセスすると、正常に取得できていました。

どうやらCloud Runへのリクエスト結果がFirebase側でキャッシュされていたようです。

以下のページを参考に、firebase.json でヘッダ情報を追加すると回避できました。

Firebase hosting: How to prevent caching for the index.html of an SPA
I’m hosting an SPA on firebase where almost all paths get rewritten to index.html. I’m using webpack hash based cache busting, so I want to always prevent caching of my index.html but not any other
"headers": [  
    {  
        "source": "/api/**",  
        "headers": [  
            {  
                "key": "Cache-Control",  
                "value": "no-cache, no-store, must-revalidate"  
            }  
        ]  
    }  
],

Firebaseのキャッシュ?

Firebaseへのデプロイ直後、ブラウザからアクセスすると、APIのレスポンスが404になることが何度かありました。ブラウザのキャッシュを消してもダメでしたが、Cloud Runへ直接アクセスしたり、FirebaseのURL(…firebaseapp.com/api/users 等)に対してローカルからcurlを叩いたりすると正常に取得できました。

しばらく放置した後にリロードすると、正常に取得できるようになっていました。おそらくこれもキャッシュが原因ぽいです。何度か同じ状況を再現できた時に試しましたが、デプロイ後30分くらい待つと正常に取れるようになりました。

回避方法があれば教えていただきたいです。

参考文献

https://qiita.com/hirotakan/items/698c1f5773a3cca6193e

https://qiita.com/Azizim_A/items/66564b5dc7597717932b

https://infraya.work/posts/alpine_with_go-singlebinary/

https://ja.nuxtjs.org/guide/installation

https://qiita.com/reflet/items/e7c33f84ab43ab237ee4

https://qiita.com/at-946/items/08de3c9d7611f62b1894

https://qiita.com/Statham/items/046649bff81c00ba275e

https://qiita.com/A-Kira/items/f401aea261693c395966

https://qiita.com/moaikids/items/f7c0db2c98425094ef10

https://firebase.google.com/docs/hosting/full-config#rewrites

https://stackoverflow.com/questions/48589821/firebase-hosting-how-to-prevent-caching-for-the-index-html-of-an-spa

https://firebase.google.com/docs/hosting/cloud-run?hl=ja

https://cloud.google.com/functions/docs/sql?hl=ja#functions_sql_mysql-go