巨人の足元でたじlog

そうして言葉を軽んじるから――― 君は私の言葉を聞き逃す

Go言語でCloud SQLとCloud Runを連携する奮闘記

接続しているっぽいところまでは割と簡単に進めましたが、本当に接続している?っていう確認に少し手間がかかりそうな気がしました。

 



 


動作確認の手順として

 


1. goでDBに接続してレコードを取ってきて表示するようなコードを書く

2. 1をイメージとしてArtifact Registryにpush

3. Cloud SQLインスタンスを作成する、データを作っておく

4. Artifact RegistryからCloud Runサービスをデプロイ(Cloud SQLに接続する設定)

5. Cloud RunがDBからレコードを取得できていることをブラウザから確認

 


という手順で進めていきたいです。

 

 

cloud.google.com

 

cloud sql自体の作成やデータアクセスはこちらから確認できますが、他のリソースからアクセスをさせたいです。

 

qiita.com

 

ローカルからアクセスする場合には、プロキシなるものを用意しないといけないっぽい。

でもGCP内でやり取りするなら、たぶんそんなのはいらない気がする。

 

 

github.com

 

これやったら動きそうな気がしてるけど、goのコードが長い。。

もう少しシンプルに接続だけを確認したい。

これは甘えか?

 

 

cloud.google.com

 

普通にこれでいいのでは?

接続方法は書いてありそうだけど、goのアプリケーションで、どうやって使うのかがわからないな。

これはgoの知識不足か。

普通にレコードselectができればいいんですが。

アプリケーションでの使用も書いてあるが、ちと情報量多いな。

github.com

 

シンプルに、コネクションを作った後の動きは別でgoのsqlパッケージの使い方として調べます。

www.wakuwakubank.com

 

これを実装してみるか。

ローカルのmysqlと接続してみることにする。

いや、これの例の通りmysqlもdockerで立ち上げることにする。

 

version: '3'

services:
  db-go-database-sql:
    image: mysql:5.7
    container_name: db-go-database-sql
    ports:
      - "13306:3306"
    volumes:
      - ./data/db:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: root_password
      MYSQL_DATABASE: test_db
      MYSQL_USER: test_user
      MYSQL_PASSWORD: test_password

とすると、エラーになりました。

 

no matching manifest for linux/arm64/v8 in the manifest list entries

 

stackoverflow.com

 

本質的な解決策じゃないようだが、とりあえず問題は解決するとのこと。

services:
  db:
    platform: linux/x86_64
    image: mysql:5.7
    ...

のようにplatform: linux/x86_64を追加してdocker compose upすると、うまく立ち上がった。

mysql -utest_user -h 127.0.0.1 --port 13306 -p

で接続後、以下実行

USE test_db;

CREATE TABLE users
(
    id         INTEGER NOT NULL AUTO_INCREMENT,
    first_name VARCHAR(50),
    last_name  VARCHAR(50),
    age        INTEGER,
    created    DATETIME DEFAULT CURRENT_TIMESTAMP,
    modified   DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE posts
(
    id       INTEGER NOT NULL AUTO_INCREMENT,
    user_id  INTEGER NOT NULL,
    content  TEXT    NOT NULL,
    created  DATETIME DEFAULT CURRENT_TIMESTAMP,
    modified DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

INSERT INTO users (first_name, last_name, age) 
VALUES 
    ("rina", "mikami", 43),
    ("jun", "kusano", 34),
    ("hideki", "yamada", 23);

 

最終的には以下のdocker-compose.yml

version: '3'

services:
  db-go-database-sql:
    platform: linux/x86_64
    image: mysql:5.7
    container_name: db-go-database-sql
    ports:
      - "13306:3306"
    volumes:
      - ./data/db:/var/lib/mysql
    environment:
      MYSQL_ROOT_PASSWORD: root_password
      MYSQL_DATABASE: test_db
      MYSQL_USER: test_user
      MYSQL_PASSWORD: test_password

これに対して、接続するようなgoのコードを作る。

main.go

package main

import (
    "database/sql"
    "errors"
    "fmt"
    "log"
    "time"

    _ "github.com/go-sql-driver/mysql"
)

type User struct {
    ID        int
    FirstName string
    LastName  string
    Age       string
    Created   time.Time
    Updated   time.Time
}

func main() {
    db, err := sql.Open("mysql", "test_user:test_password@tcp(127.0.0.1:13306)/test_db?parseTime=true&loc=Asia%2FTokyo")
    if err != nil {
        log.Fatalf("main sql.Open error err:%v", err)
    }
    defer db.Close()

    fmt.Println("------------------")
    getRows(db)
    fmt.Println("------------------")
    getSingleRow(db, 1)
    fmt.Println("------------------")
    getSingleRow(db, 4) // 存在しないUserID
}

func getRows(db *sql.DB) {
    rows, err := db.Query("SELECT * FROM users")
    if err != nil {
        log.Fatalf("getRows db.Query error err:%v", err)
    }
    defer rows.Close()

    for rows.Next() {
        u := &User{}
        if err := rows.Scan(&u.ID, &u.FirstName, &u.LastName, &u.Age, &u.Created, &u.Updated); err != nil {
            log.Fatalf("getRows rows.Scan error err:%v", err)
        }
        fmt.Println(u)
    }

    err = rows.Err()
    if err != nil {
        log.Fatalf("getRows rows.Err error err:%v", err)
    }
}

func getSingleRow(db *sql.DB, userID int) {
    u := &User{}
    err := db.QueryRow("SELECT * FROM users WHERE id = ?", userID).
        Scan(&u.ID, &u.FirstName, &u.LastName, &u.Age, &u.Created, &u.Updated)
    if errors.Is(err, sql.ErrNoRows) {
        fmt.Println("getSingleRow no records.")
        return
    }
    if err != nil {
        log.Fatalf("getSingleRow db.QueryRow error err:%v", err)
    }
    fmt.Println(u)
}

goの外部パッケージ追加方法は、まずmain.goと同じディレクトリ内で

go mod init

で始めてから、

main.goにコードを書いた状態で、

go mod tidy

をする。

すると、その中でimportしているがmodに追加されていないものを自動でimportしてくれる。

これで疎通確認できました!

これでとりあえず、mysqlからデータを取得するgoのコードが確認できました。

中身も見てみて、なるほどねって感じです。Goライクな書き方にだんだんと慣れてきている気がします。

 

これをそのままCloud Runにデプロイしようと思います。

Dockerfileは公式より、

docs.docker.com

これをベースにして、

# syntax=docker/dockerfile:1

FROM golang:1.16-alpine

WORKDIR /app

COPY go.mod ./
COPY go.sum ./
RUN go mod download

COPY *.go ./

RUN go build -o /docker-gs-ping

EXPOSE 8080

CMD [ "/docker-gs-ping" ]

 

 

まずは疎通確認のため、goのコードもこのサンプルのまま使います。

main.go

package main

import (
    "net/http"
    "os"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

func main() {

    e := echo.New()

    e.Use(middleware.Logger())
    e.Use(middleware.Recover())

    e.GET("/", func(c echo.Context) error {
        return c.HTML(http.StatusOK, "Hello, Docker! <3")
    })

    e.GET("/ping", func(c echo.Context) error {
        return c.JSON(http.StatusOK, struct{ Status string }{Status: "OK"})
    })

    httpPort := os.Getenv("HTTP_PORT")
    if httpPort == "" {
        httpPort = "8080"
    }

    e.Logger.Fatal(e.Start(":" + httpPort))
}

 

タグを付けてイメージをbuildする

docker build . -t asia-northeast1-docker.pkg.dev/MY_PROJECT/cloud-run-source-deploy/go-test:latest

 

Artifact Registryにpushする

docker push asia-northeast1-docker.pkg.dev/MY_PROJECT/cloud-run-source-deploy/go-test:latest

 

Artifact Registryにimageがpushされているのを確認したら、コンソールからCloud Runサービスをデプロイ

 

おっと、ここでエラー。

The user-provided container failed to start and listen on the port defined provided by the PORT=8080 environment variable. Logs for this revision might contain more information. Logs URL:

これは以前解決したエラーでした。

my0shym.hatenablog.com

 

ここで解決していますが、buildコマンドを以下に変更です。

docker build . -t go-test --platform linux/amd64

再度pushして、Artifact RegistryからCloud Runにデプロイします。

公開されているURLからアクセスすると、

Hello, Docker! <3

の文字が表示されていました。

これでDockerfileが正しく動くことが確認できました。

これを次は、先程のDB接続用のmain.goに変更してpushします。

本当はDB接続情報は環境変数にセットして使用したいところですが、一旦決め打ちで作って動作を確認してから環境変数化していきたいと思います。

 

と思ったけど、このままだと、サーバーとしての機能は持たせていないからhttpリクエスト送ってもだめだ。

まずサーバーとしての機能は維持しつつ、handefuncでmysqlからデータを取得するように組み合わせる必要があるな。

Dockerfileのサンプルコードを使ってもいいんだけど、echoのモジュールを使ってリクエストの処理をしていたので、これは使わないで、シンプルにnet/httpの標準モジュールを使っていく。

以下より、まずはシンプルなwebサーバーとして動かす。

すごい回り道してきたきがするなぁ。

qiita.com

 

package main

import (
    "fmt"
    "log"
    "net/http"
)

func hello_world(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello World")
}

func main() {
    http.HandleFunc("/", hello_world)

    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal("ListenAndServe: ", err)
    }
}

 

これをpushしてArtifactからCloud Runのサービスデプロイ。

→ URLアクセスでHello, Worldが表示された!

 

これのHandleFuncにmysqlからデータを取ってくる関数を追加しよう。

 

package main

import (
    "database/sql"
    "errors"
    "fmt"
    "log"
    "net/http"
    "time"

    _ "github.com/go-sql-driver/mysql"
)

type User struct {
    ID        int
    FirstName string
    LastName  string
    Age       string
    Created   time.Time
    Updated   time.Time
}

func getSingleRow(db *sql.DB, userID int) string {
    u := &User{}
    err := db.QueryRow("SELECT * FROM users WHERE id = ?", userID).
        Scan(&u.ID, &u.FirstName, &u.LastName, &u.Age, &u.Created, &u.Updated)
    if errors.Is(err, sql.ErrNoRows) {
        fmt.Println("getSingleRow no records.")
        return u.FirstName
    }
    if err != nil {
        log.Fatalf("getSingleRow db.QueryRow error err:%v", err)
    }
    // fmt.Println(u)
    return u.FirstName
}

func hello_world(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello World")
}

func sql_man(w http.ResponseWriter, r *http.Request) {
    db, err := sql.Open("mysql", "test_user:test_password@tcp(127.0.0.1:13306)/test_db?parseTime=true&loc=Asia%2FTokyo")
    if err != nil {
        log.Fatalf("main sql.Open error err:%v", err)
    }
    defer db.Close()

    u := getSingleRow(db, 1)
    fmt.Fprintf(w, u)
}

func main() {

    http.HandleFunc("/", hello_world)
    http.HandleFunc("/sql-man", sql_man)

    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal("ListenAndServe: ", err)
    }
}

 

これでデプロイすると、

/ ではHello, worldが表示されるが、

/sql-man ではService Unavailable と表示される。

これはcloud sqlをまだ設定していないので正しい。

ここからcloud sqlの設定を入れていく。

と、公式のつなぎ込み方を再度確認すると、自分のコードではtcpで疎通している部分がunixソケットで疎通している。

cloud.google.com

unixソケットについて整理します。

 

qiita.com

 

UNIX ドメインソケットもソケットもいずれもプロセス間のデータのやり取りを行うための手法の一つ
UNIX ドメインソケットではプロセス間通信にファイルシステムを利用する(拡張子 .sock という場合が多い)その為、 同じホストでのプロセス間通信 として利用される
・ソケット通信は 異なるホスト で TCPUDP を使ってプロセス間のやり取りが可能

 

Unix socket connectionってので通信しているんだが、これはソケット通信なのか、unixドメインソケット通信なのかどっちなんだ?

そこまでは把握する必要はないか?

形式的には

/cloudsql/project:region:instance

みたいな感じだから、異なるホストっていう扱いなのかな?

このあたりは宿題として放置。まず動くことを目標とします。

 

元々Cloud SQLインスタンスは作成済みだったので、それを使用します。

 

以下コードで疎通の確認ができた!

package main

import (
    "database/sql"
    "errors"
    "fmt"
    "log"
    "net/http"
    "time"

    _ "github.com/go-sql-driver/mysql"
)

type User struct {
    ID        int
    FirstName string
    LastName  string
    Age       string
    Created   time.Time
    Updated   time.Time
}

func getSingleRow(db *sql.DB, userID int) string {
    u := &User{}
    err := db.QueryRow("SELECT * FROM users WHERE id = ?", userID).
        Scan(&u.ID, &u.FirstName, &u.LastName, &u.Age, &u.Created, &u.Updated)
    if errors.Is(err, sql.ErrNoRows) {
        fmt.Println("getSingleRow no records.")
        return u.FirstName
    }
    if err != nil {
        log.Fatalf("getSingleRow db.QueryRow error err:%v", err)
    }
    // fmt.Println(u)
    return u.FirstName
}

func hello_world(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello World")
}
func connectUnixSocket() (*sql.DB, error) {
    // mustGetenv := func(k string) string {
    //     v := os.Getenv(k)
    //     if v == "" {
    //         log.Fatalf("Warning: %s environment variable not set.", k)
    //     }
    //     return v
    // }
    // Note: Saving credentials in environment variables is convenient, but not
    // secure - consider a more secure solution such as
    // Cloud Secret Manager (https://cloud.google.com/secret-manager) to help
    // keep secrets safe.
    var (
        dbUser         = "root"
        dbPwd          = "hogehoge01"
        dbName         = "test_db"
        unixSocketPath = "/cloudsql/MY_PROJECT:us-central1:myinstance"
        // dbUser         = mustGetenv("DB_USER")              // e.g. 'my-db-user'
        // dbPwd          = mustGetenv("DB_PASS")              // e.g. 'my-db-password'
        // dbName         = mustGetenv("DB_NAME")              // e.g. 'my-database'
        // unixSocketPath = mustGetenv("INSTANCE_UNIX_SOCKET") // e.g. '/cloudsql/project:region:instance'
    )

    dbURI := fmt.Sprintf("%s:%s@unix(/%s)/%s?parseTime=true",
        dbUser, dbPwd, unixSocketPath, dbName)

    // dbPool is the pool of database connections.
    dbPool, err := sql.Open("mysql", dbURI)
    if err != nil {
        return nil, fmt.Errorf("sql.Open: %v", err)
    }

    // ...

    return dbPool, nil
}

func sql_man(w http.ResponseWriter, r *http.Request) {
    // db, err := sql.Open("mysql", "test_user:test_password@tcp(127.0.0.1:13306)/test_db?parseTime=true&loc=Asia%2FTokyo")
    db, err := connectUnixSocket()
    if err != nil {
        log.Fatalf("main sql.Open error err:%v", err)
    }
    defer db.Close()

    u := getSingleRow(db, 1)
    fmt.Fprintf(w, u)
}

func main() {

    http.HandleFunc("/", hello_world)
    http.HandleFunc("/sql-man", sql_man)

    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal("ListenAndServe: ", err)
    }
}

 

これを一応環境変数を読み取るように変更する。

ここらへんは問題なく進めるはず。

コメントアウト部分を復活させたものをArtifactにpushしてからCloud Runにデプロイ、その際に環境変数

DB_USER
DB_PASS
DB_NAME
INSTANCE_UNIX_SOCKET

を設定したら、いけました!

 

くぅ〜これにて完結です。

本当は上の4つの値はシークレットマネージャーで管理したほうが良さそうなんだけども、それはまたのお話ということで。

どっかできれいにしてまとめたいです。