kenya6111のブログ

kenya6111の成長記録。

【Docker】Golangのwebアプリケーションをdocker化する(3)

今回はマルチステージビルドをします。

とその前に、livereloadができるツール,Airを導入しようと思います。

こちら導入後、マルチステージビルド対応の際に開発環境だけに適用するようにしたいと思います。

Airは、Goで実装したアプリケーションをホットリロードしてくれるツールです。

github.com


以下のようにDockerfileを修正します

FROM golang:1.18-alpine

WORKDIR /go/src

COPY . .

RUN go install github.com/air-verse/air@latest ←追加!!!

RUN go mod download

RUN go build -o main ./main.go

// CMD ["./main"]
CMD ["air", "-c", ".air.toml"] ←追加修正

それでは、docker compose up !

早速エラー発生。

package slices is not in GOROOT (/usr/local/go/src/slices) 

golang:1.18-alipneにはスライスがまだ実装されていないようです。

なのでバージョン上げろってことですね。

stackoverflow.com

- FROM golang:1.18-alpine
+ FROM golang:tip-alpine3.22


再度、docker compose up!!!

起動されました〜〜!

しかし、mainファイルをいじってもホットリロードできていない様子。。。

更新してもビルドが走りません。。なんでだろう。。

これは速攻で解決したのですが、

APIコンテナでvolumeを設定していませんでした(ホスト側でmain.goを修正しても、コンテナ内にその修正が反映されてない)

docker-compose.ymlを以下のように修正します

services:
  go:
    build:
      dockerfile: ./docker/go/Dockerfile
      context: .
    container_name: web-server-gin 
    ports:
      - "8080:8080"
    depends_on: 
      db:
        condition: service_healthy 
    volumes:             ←追加!!! 
      - ./:/go/src
    environment:
      - DB_USER=${DB_USER}
      - DB_PASSWORD=${DB_PASSWORD}
      - DB_HOST=${DB_HOST}
      - DB_PORT=${DB_PORT}
      - DB_NAME=${DB_NAME}
    working_dir: /go/src
    stdin_open: true
  db:
    image: mysql:5.7
    platform: linux/x86_64
    container_name: web-server-gin-mysql
    ports:
      - "3306:3306"
    volumes:
      - my-db:/var/lib/mysql/
      - ./sql/init:/docker-entrypoint-initdb.d
    environment:
      - MYSQL_USER=${MYSQL_USER}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_DATABASE=${MYSQL_DATABASE}
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} 
      - MYSQL_ALLOW_EMPTY_PASSWORD=${MYSQL_ALLOW_EMPTY_PASSWORD} 
      - MYSQL_RANDOM_ROOT_PASSWORD=${MYSQL_RANDOM_ROOT_PASSWORD} 
    healthcheck:
      test:
        [
          "CMD",
          "mysqladmin",
          "ping",
          "-h",
          "localhost",
          "-u",
          "mysql",
          "-pmypassword",
        ]
      timeout: 20s
      retries: 10

volumes:
  my-db:

ローカルで編集したgoファイルをコンテナ内に反映できていなかったようですね!

再度、docker compose up !!!

無事、起動。

goファイルを編集してみる

 -c.IndentedJSON(http.StatusOK, gin.H{"message": "Hello World!!!"})
 +c.IndentedJSON(http.StatusOK, gin.H{"message": "Hello World!!!!!!!!!!!!!!!!!!!!!!!!!"})

リロードしてますね〜〜!


ホットリロード完了しました!


さて、本題のマルチステージビルドです。

今回主に参考にした記事は以下です。よくまとまっていてわかりやすいです。

zenn.dev qiita.com

今回、開発環境用と本番環境用でマルチステージビルドをします。

イメージとしては 以下のような感じです。

dev ステージ → 工場でビルド(原材料・工具・作業場あり)
prod ステージ → 完成品(箱詰めされた商品)だけ持ってくる倉庫

工場の作業員や工具は、商品には入らない。
→ だからイメージが軽くなる

なので大まかな修正としては以下のようになります

  • Dockerfileにdevステージとprodステージをそれぞれ記載

  • docker-compose.ymlを開発用と本番用に分ける


では修正していきましょう。


まずDockerfileから

    FROM golang:tip-alpine3.22 AS dev
    WORKDIR /go/src
    COPY . .
    RUN go install github.com/air-verse/air@latest
    RUN go mod download
    RUN go build -o main ./main.go
    CMD ["air", "-c", ".air.toml"]


    FROM alpine:3.22.1 AS prod
    WORKDIR /app
    COPY --from=dev /go/src/main .
    COPY . .
    CMD [ "/app/main" ]

Dockerfile内で FROMを新たに書くと、完全に別のイメージが作られます

FROM を新たに書いているので

dev ステージで使ってたもの(Go, Air, go.mod など)は prod ステージに引き継がれません。これがマルチステージビルドの肝です。

引き継ぎたいものはCOPY --from=dev {引き継ぎたいもののパス} でその新たなステージにコピーします。

FROM golang:alpine AS dev
# → GoやAirをインストール、build、mainバイナリ作成

FROM alpine AS prod
# → 完全に別の新しいイメージ
# devステージから欲しいファイルだけ手動でCOPYする
COPY --from=dev /go/src/main .


次にdocker-compose.ymlです。 現場のdocker-compose.ymlは以下の通りです。

services:
  go:
    build:
      dockerfile: ./docker/go/Dockerfile
      context: .
    container_name: web-server-gin
    ports: 
      - "8080:8080"
    depends_on: 
      db:
        condition: service_healthy
    volumes:
      - ./:/go/src
    environment:
      - DB_USER=${DB_USER}
      - DB_PASSWORD=${DB_PASSWORD}
      - DB_HOST=${DB_HOST}
      - DB_PORT=${DB_PORT}
      - DB_NAME=${DB_NAME}
    working_dir: /go/src
    stdin_open: true
  db:
    image: mysql:5.7
    platform: linux/x86_64
    container_name: web-server-gin-mysql
    ports:
      - "3306:3306"
    volumes:
      - my-db:/var/lib/mysql/
      - ./sql/init:/docker-entrypoint-initdb.d
    environment:
      - MYSQL_USER=${MYSQL_USER}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_DATABASE=${MYSQL_DATABASE}
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
      - MYSQL_ALLOW_EMPTY_PASSWORD=${MYSQL_ALLOW_EMPTY_PASSWORD} 
      - MYSQL_RANDOM_ROOT_PASSWORD=${MYSQL_RANDOM_ROOT_PASSWORD} 
    healthcheck:
      test:
        [
          "CMD",
          "mysqladmin",
          "ping",
          "-h",
          "localhost",
          "-u",
          "mysql",
          "-pmypassword",
        ]
      timeout: 20s
      retries: 10

volumes:
  my-db: 


先ほども書いた通り、本番環境用と開発環境用にdocker-compose.ymlを分けます

ではまず既存のdocker-compose.ymlを本番環境用として修正します ※本番環境用のdocker-compose.ymlのファイル名はそのまま「docker-compose.yml」としています。 修正点は以下の通りです。

services:
  go:
    build:
      dockerfile: ./docker/go/Dockerfile
      context: .
      target: prod              ←追記!!!
    container_name: web-server-gin
    ports: 
      - "8080:8080"
    depends_on: 
      db:
        condition: service_healthy
    volumes:
      - ./:/go/src
    environment:
      - DB_USER=${DB_USER}
      - DB_PASSWORD=${DB_PASSWORD}
      - DB_HOST=${DB_HOST}
      - DB_PORT=${DB_PORT}
      - DB_NAME=${DB_NAME}
    working_dir: /go/src
    stdin_open: true
  db:
    image: mysql:5.7
    platform: linux/x86_64
    container_name: web-server-gin-mysql
    ports:
      - "3306:3306"
    volumes:
      - my-db:/var/lib/mysql/
      - ./sql/init:/docker-entrypoint-initdb.d
    environment:
      - MYSQL_USER=${MYSQL_USER}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_DATABASE=${MYSQL_DATABASE}
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
      - MYSQL_ALLOW_EMPTY_PASSWORD=${MYSQL_ALLOW_EMPTY_PASSWORD} 
      - MYSQL_RANDOM_ROOT_PASSWORD=${MYSQL_RANDOM_ROOT_PASSWORD} 
    healthcheck:
      test:
        [
          "CMD",
          "mysqladmin",
          "ping",
          "-h",
          "localhost",
          "-u",
          "mysql",
          "-pmypassword",
        ]
      timeout: 20s
      retries: 10

volumes:
  my-db: 

修正点は

  go:
    build:
      dockerfile: ./docker/go/Dockerfile
      context: .
      target: prod              ←追記!!!

targetのラインを追加するだけです

こちらはDockerfile内のどのステージをビルドしますか?の設定になります。

本番環境用のdocker-compose.ymlになるのでprodとしています。

先ほどはったDockerfileにて

    FROM alpine:3.22.1 AS prod

と記載ありましたね。これを指定しています。


続いて開発環境用のdocker-compose.ymlです。 こちらはファイル名を「docker-compose.dev.yml」として作成します

こちらもtargetを既存のdocker-compose.ymlにtargetを追記するだけです。

  go:
    build:
      dockerfile: ./docker/go/Dockerfile
      context: .
      target: dev              ←追記!!!

はい、修正は以上です。

では実際に開発用と本番用を起動してみましょう。

開発環境の場合

2. イメージをビルド

docker compose -f docker-compose.dev.yml build

ビルド成功です。

ログを見ると、確かにdevステージ内のラインのみ実行できていますね。

3. コンテナを起動

docker compose up -d
  • Air による ソースのライブリロード が有効になります
  • http://localhost:8080 にアクセスして動作を確認

開発環境が起動できました

ここでdocker-compose.dev.ymlで生成したイメージの容量を見てみましょう。 667MBです。

この容量を覚えておきてください。

Goのツールなどビルドに必要なものが諸々はいっているので容量が大きいですね。(本番では容量が小さくなります)

それと、開発環境なのでAirが有効になっているはずです。goのソース修正と同時にホットリロードが走るはずです。(本番では不要なため効かないようになります)

さて、次に本番用を起動してみます 一度 docker compose down -v docker rmi でコンテナとイメージを消しましょう。


本番環境の場合

2. イメージをビルド

docker compose build

3. コンテナを起動

docker compose up -d

起動完了しました。

今度は「docker compse build」で起動しているので、デフォルトでdocker-compose.ymlがビルドに使われています

docker-compose.ymlではtargetをprodとしていたので、本番用のビルドが走りますね。

Dockerfile内の

    FROM golang:tip-alpine3.22 AS dev
    WORKDIR /go/src
    COPY . .
    RUN go install github.com/air-verse/air@latest
    RUN go mod download
    RUN go build -o main ./main.go
    CMD ["air", "-c", ".air.toml"]


    FROM alpine:3.22.1 AS prod
    WORKDIR /app
    COPY --from=dev /go/src/main .
    COPY . .
    CMD [ "/app/main" ]

こちらのprodステージのイメージが生成されます。

target prodとしているので Dokcerfile内のprodステージだけ実行されるのかと思いきや、 devもしっかり実行しています。

prodではdevステージで作ったビルド済ファイルを持ってきて、実行するだけです。

実際に生成したイメージを見てみると

容量が20.3MB!! かなり縮小できています。

これがマルチステージビルドの美味しいところです。

あと起動してみると本番ではAirはないので、ホットリロードは起こりません。

あと開発用と本番用で必要のものを分けられる点も美味しいところです。

以上、拙い文章で、ここまで読んでくださりありがとうございました。

【Docker】Golangのwebアプリケーションをdocker化する(2)

というわけで次はDBコンテナを起動しようと思います。 以下、遭遇したエラーとその対処などつらつらと書いていきます


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

コンテナ起動時に出たエラー。

docker compose up db --build

当方M3Macで開発しているため、過去にもよく見たエラーだ。

k_tanaka@tanakakenyanoMacBook-Air web-service-gin % docker compose up db         
[+] Running 0/1
 ⠼ db Pulling                                                                                                                     2.4s 
no matching manifest for linux/arm64/v8 in the manifest list entries

解決方法はdocker-compose.ymlに以下を追記すればOK

db:
    image: mysql:5.7
    platform: linux/x86_64 ←これを追記
    container_name: web-server-gin-mysql
    ports:
      - "3306:3306"
    volumes:
      - my-db:/var/lib/mysql/
      - ./sql/init:/docker-entrypoint-init.d
    environment:
      - MYSQL_USER=${MYSQL_USER}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} 
      - MYSQL_ALLOW_EMPTY_PASSWORD=${MYSQL_ALLOW_EMPTY_PASSWORD} 



docker-entrypoint-initdb.d の初期化でデータが作られない。

docker-compose.ymlでdbのサービスを以下のように記載し

  db:
    image: mysql:5.7
    platform: linux/x86_64
    container_name: web-server-gin-mysql
    ports:
      - "3306:3306"
    volumes:
      - my-db:/var/lib/mysql/
      - ./sql/init:/docker-entrypoint-initdb.d ←これ!!!
    environment:
      - MYSQL_USER=${MYSQL_USER}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} 
      - MYSQL_ALLOW_EMPTY_PASSWORD=${MYSQL_ALLOW_EMPTY_PASSWORD} 
      - MYSQL_RANDOM_ROOT_PASSWORD=${MYSQL_RANDOM_ROOT_PASSWORD}

./sql/init:/docker-entrypoint-initdb.dをかき

./sql/init/init.sqlは以下の通り記載している。

create database testdb;
user testdb

DROP TABLE IF EXISTS album;

CREATE TABLE album (
  id     INT AUTO_INCREMENT NOT NULL,
  title  VARCHAR(128) NOT NULL,
  artist VARCHAR(255) NOT NULL,
  price  DECIMAL(5,2) NOT NULL,
  PRIMARY KEY (id)
);

INSERT INTO album (title, artist, price)
VALUES
  ('Blue Train', 'John Coltrane', 56.99),
  ('Giant Steps', 'John Coltrane', 63.99),
  ('Jeru', 'Gerry Mulligan', 17.99),
  ('Sarah Vaughan', 'Sarah Vaughan', 34.98);

という格好で書いているのに、docker compose up してDBコンテナの中身に行っても、

mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
+--------------------+
1 row in set (0.02 sec)

testdb作られてない・・・

なんでや(泣)(泣)(泣)(泣)(泣)(泣)

と色々調べていると

MYSQL_DATABASEという環境変数を設定すればDB初期化時にCREATE DATABASEを勝手にしてくれるとのこと。。

ymlに環境変数を追記

  db:
    image: mysql:5.7
    platform: linux/x86_64
    container_name: web-server-gin-mysql
    ports:
      - "3306:3306"
    volumes:
      - my-db:/var/lib/mysql/
      - ./sql/init:/docker-entrypoint-initdb.d
    environment:
      - MYSQL_USER=${MYSQL_USER}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_DATABASE=${MYSQL_DATABASE}     ←追加!!!!!!

これでdocker compose upすると無事DB作られました!!

mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| testdb             |
+--------------------+
2 rows in set (0.03 sec)



DB起動前にAPIがアクセスしてしまいAPIが落ちる

docker-compose.ymlは以下のような状態

depends_onを設定したのでちゃんとDB→APIの順番で起動するので大丈夫!。ではなかった。

どうやらdepends_onは起動順序を保証するだけで、起動が終わるまで待ちはしないみたいです。

なので起動順序はDB→APIの順で立ち上がっていますが、DBが起動し終わる前にAPIがアクセスしてしまい、APIコンテナが落ちてしまっていたようです。

qiita.com

services:
  go:
    build:
      dockerfile: ./docker/go/Dockerfile
      context: .
    container_name: web-server-gin ##任意のコンテナ名を指定する
    ports:
      - "8080:8080"
    depends_on:
      - db
    environment:
      - DB_USER=${DB_USER}
      - DB_PASSWORD=${DB_PASSWORD}
      - DB_HOST=${DB_HOST}
      - DB_PORT=${DB_PORT}
      - DB_NAME=${DB_NAME}
    working_dir: /go/src
    stdin_open: true
  db:
    image: mysql:5.7
    platform: linux/x86_64
    container_name: web-server-gin-mysql
    ports:
      - "3306:3306"
    volumes:
      - my-db:/var/lib/mysql/
      - ./sql/init:/docker-entrypoint-initdb.d
    environment:
      - MYSQL_USER=${MYSQL_USER}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_DATABASE=${MYSQL_DATABASE}
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} 
      - MYSQL_ALLOW_EMPTY_PASSWORD=${MYSQL_ALLOW_EMPTY_PASSWORD}
      - MYSQL_RANDOM_ROOT_PASSWORD=${MYSQL_RANDOM_ROOT_PASSWORD} 

volumes:
  my-db: 


なので以下のように修正。

services:
  go:
    build:
      dockerfile: ./docker/go/Dockerfile
      context: .
    container_name: web-server-gin
    ports:
      - "8080:8080"
    depends_on: 
      db:
        condition: service_healthy ←追記!!
    environment:
      - DB_USER=${DB_USER}
      - DB_PASSWORD=${DB_PASSWORD}
      - DB_HOST=${DB_HOST}
      - DB_PORT=${DB_PORT}
      - DB_NAME=${DB_NAME}
    working_dir: /go/src
    stdin_open: true
  db:
    image: mysql:5.7
    platform: linux/x86_64
    container_name: web-server-gin-mysql
    ports:
      - "3306:3306"
    volumes:
      - my-db:/var/lib/mysql/
      - ./sql/init:/docker-entrypoint-initdb.d
    environment:
      - MYSQL_USER=${MYSQL_USER}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_DATABASE=${MYSQL_DATABASE}
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} 
      - MYSQL_ALLOW_EMPTY_PASSWORD=${MYSQL_ALLOW_EMPTY_PASSWORD} 
      - MYSQL_RANDOM_ROOT_PASSWORD=${MYSQL_RANDOM_ROOT_PASSWORD} 
    healthcheck: ←追記!!
      test:
        [
          "CMD",
          "mysqladmin",
          "ping",
          "-h",
          "localhost",
          "-u",
          "mysql",
          "-pmypassword",
        ]
      timeout: 20s
      retries: 10

volumes: 
  my-db: 


ちなみに以下の記事参考にしました。

stackoverflow.com

blog.mtb-production.info

DBは作られたが肝心の初期化スクリプトが実行されていない

次々と不具合がでますね(汗)(汗)(汗)

APIコンテナとDBコンテナに疎通できたものの、肝心の init.sqlが実行できていませんでした。

mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| testdb             |
+--------------------+
2 rows in set (0.02 sec)

mysql> use testdb;
Database changed
mysql> show tables;

Volumeが残っているのが原因かと思い以下の記事試しましたが解決せず。。

qiita.com

結果

    volumes:
      - my-db:/var/lib/mysql/
      - ./sql/init:/docker-entrypoint-initdb.d

って書いてるにも関わらず、 init.sql

sql/init/init.sqlではなく

sql/init.sql

においてました😀

お茶目してしまいました。。

というわけで、APIコンテナとDBコンテナの起動、連携がうまくできました!!

次回、マルチステージビルドを用いて開発用(development)と本番用(production)で処理をわける処理書いていきます!

それでは!!

【Docker】Golangのwebアプリケーションをdocker化する (1)

とりあえずdocker runで起動できるようにしてみる

Docker化するサンプルアプリはこちら

package main

import (
    "database/sql"
    "fmt"
    "log"
    "net/http"
    "os"

    "github.com/gin-gonic/gin"
    "github.com/go-sql-driver/mysql"
)

var db *sql.DB

// album represents data about a record album.
type Album struct {
    ID     int64   `json:"id"`
    Title  string  `json:"title"`
    Artist string  `json:"artist"`
    Price  float64 `json:"price"`
}

func home(c *gin.Context) {
    c.IndentedJSON(http.StatusOK, gin.H{"message": "Hello World!"})
}

func getAlbums(c *gin.Context) {
    var albums []Album

    rows, err := db.Query("SELECT * FROM album")

    if err != nil {
        return
    }

    defer rows.Close()

    for rows.Next() {
        var album Album
        if err := rows.Scan(&album.ID, &album.Title, &album.Artist, &album.Price); err != nil {
            c.IndentedJSON(http.StatusInternalServerError, gin.H{"message": err})
            return
        }
        albums = append(albums, album)
        if err := rows.Err(); err != nil {
            c.IndentedJSON(http.StatusInternalServerError, gin.H{"message": err})
            return
        }
    }
    c.IndentedJSON(http.StatusOK, albums)
}

func postAlbums(c *gin.Context) {
    var newAlbum Album

    err := c.BindJSON(&newAlbum)

    if err != nil {
        c.IndentedJSON(http.StatusUnprocessableEntity, gin.H{"message": err})
        return
    }

    result, err := db.Exec("INSERT INTO album (title, artist, price) VALUES (?, ?, ?)", newAlbum.Title, newAlbum.Artist, newAlbum.Price)
    if err != nil {
        c.IndentedJSON(http.StatusUnprocessableEntity, gin.H{"message": err})
        return
    }

    id, err := result.LastInsertId()
    if err != nil {
        c.IndentedJSON(http.StatusUnprocessableEntity, gin.H{"message": err})
        return
    }

    var createdAlbum Album
    createdAlbum.ID = id
    createdAlbum.Artist = newAlbum.Artist
    createdAlbum.Price = newAlbum.Price
    createdAlbum.Title = newAlbum.Title
    c.IndentedJSON(http.StatusCreated, &createdAlbum)
}

func getAlbumByID(c *gin.Context) {
    id := c.Param("id")

    var album Album

    row := db.QueryRow("SELECT * FROM album WHERE id = ?", id)
    if err := row.Scan(&album.ID, &album.Title, &album.Artist, &album.Price); err != nil {
        if err == sql.ErrNoRows {
            c.IndentedJSON(http.StatusNotFound, gin.H{"message": err})
            return
        }
    }
    c.IndentedJSON(http.StatusOK, &album)
}

func updateAlbumByID(c *gin.Context) {
    var updateAlbum Album

    id := c.Param("id")

    if err := c.BindJSON(&updateAlbum); err != nil {
        c.IndentedJSON(http.StatusBadRequest, gin.H{"message": "bind error"})
        return
    }

    _, err := db.Exec("UPDATE album SET title = ?, artist = ?, price = ? WHERE id = ?", updateAlbum.Title, updateAlbum.Artist, updateAlbum.Price, id)
    if err != nil {
        c.IndentedJSON(http.StatusBadRequest, gin.H{"message": "update error"})
        return
    }

    c.IndentedJSON(http.StatusNoContent, &updateAlbum)
}

func deleteAlbumByID(c *gin.Context) {
    id := c.Param("id")

    var album Album

    row := db.QueryRow("SELECT * FROM album WHERE id = ?", id)
    if err := row.Scan(&album.ID, &album.Title, &album.Artist, &album.Price); err != nil {
        if err == sql.ErrNoRows {
            c.IndentedJSON(http.StatusNotFound, gin.H{"message": err})
            return
        }
    }

    if _, err := db.Exec("DELETE FROM album WHERE id = ?", id); err != nil {
        c.IndentedJSON(http.StatusUnprocessableEntity, gin.H{"message": err})
        return
    }

    c.IndentedJSON(http.StatusNoContent, &album)
}

func main() {
    config := mysql.Config{
        User:                 os.Getenv("DB_USER"),
        Passwd:               os.Getenv("DB_PASSWORD"),
        Net:                  "tcp",
        Addr:                 fmt.Sprintf("%s:%s", os.Getenv("DB_HOST"), os.Getenv("DB_PORT")),
        DBName:               os.Getenv("DB_NAME"),
        AllowNativePasswords: false,
    }

    fmt.Printf("%s:%s@tcp(%s:%s)/%s",
        os.Getenv("DB_USER"),
        os.Getenv("DB_PASSWORD"),
        os.Getenv("DB_HOST"),
        os.Getenv("DB_PORT"),
        os.Getenv("DB_NAME"),
    )

    fmt.Println(config.FormatDSN())

    var err error
    db, err = sql.Open("mysql", config.FormatDSN())
    if err != nil {
        log.Fatal(err)
    }

    pingErr := db.Ping()
    if pingErr != nil {
        log.Fatal(pingErr)
    }
    fmt.Println("Connected!")

    router := gin.Default()
    router.GET("/", home)
    router.GET("/albums", getAlbums)
    router.GET("/albums/:id", getAlbumByID)
    router.POST("/albums", postAlbums)
    router.PATCH("/albums/:id", updateAlbumByID)
    router.DELETE("/albums/:id", deleteAlbumByID)

    router.Run()
}


  • envファイルをまず記載する
DB_USER=root
DB_PASSWORD=password
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=testdb


  • 次にDockerfile作成
// Dockerfile

# 注意 go.mod記載のversionと合わせる
# 軽量にするためalpineを使用
FROM golang:1.18-alpine

# コンテナ内の作業ディレクトリを/appに設定
WORKDIR /app

# ホストマシンからtodo-app内の全ファイルを、コンテナ内の作業ディレクトリ(/app)にコピー
COPY . .

# go.modファイルに記載された依存パッケージをdownload
RUN go mod download

# main.goファイルをコンパイルして、実行ファイルを作成
RUN go build -o main /app/main.go

# コンテナ起動後に、実行ファイルを実行
CMD ["/app/main"]


  • docker runで起動!
docker run -p 8080:80 --name web-server-gin2 web-server-gin
tcp(:)/?allowNativePasswords=false&checkConnLiveness=false&maxAllowedPacket=0
2025/07/18 22:58:15 dial tcp :0: connect: connection refused

ありゃ?接続拒否?? いやそうじゃん。環境変数渡せてないじゃん。。


  • docker runで起動!(2回目)
web-service-gin % docker run -p 8080:80 \ 
  -e DB_USER=root \
  -e DB_PASSWORD=password \
  -e DB_HOST=127.0.0.1 \
  -e DB_PORT=3306 \
  -e DB_NAME=testdb \
  --name web-server-gin web-server-gin
root:password@tcp(127.0.0.1:3306)/testdb?allowNativePasswords=false&checkConnLiveness=false&maxAllowedPacket=0
2025/07/18 23:36:17 dial tcp 127.0.0.1:3306: connect: connection refused

あれ???

また接続拒否。。。

てか-eオプションめんどくさいな

調べると以下の書き方があるらしいので試す


docker run -p 8080:80 --env-file .env --name web-server-gin web-server-gin

root:password@tcp(127.0.0.1:3306)/testdb?allowNativePasswords=false&checkConnLiveness=false&maxAllowedPacket=0
2025/07/18 23:41:48 dial tcp 127.0.0.1:3306: connect: connection refused


まあ結果変わらんよな。

調べると、コンテナ上でのlovalhostはコンテナ自身を指しているのでhostを指す値を指定する必要があるらしい

zenn.dev

な訳でDB _HOSTを「host.docker.internal」に修正

DB_USER=root
DB_PASSWORD=password
DB_HOST=host.docker.internal
DB_PORT=3306
DB_NAME=testdb


  • docker runで起動(30回目)
k_tanaka@tanakakenyanoMacBook-Air web-service-gin % docker run -p 8080:80 --env-file .env --name web-server-gin web-server-gin
root:password@tcp(host.docker.internal:3306)/testdb?allowNativePasswords=false&checkConnLiveness=false&maxAllowedPacket=0
Connected!
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /                         --> main.home (3 handlers)
[GIN-debug] GET    /albums                   --> main.getAlbums (3 handlers)
[GIN-debug] GET    /albums/:id               --> main.getAlbumByID (3 handlers)
[GIN-debug] POST   /albums                   --> main.postAlbums (3 handlers)
[GIN-debug] PATCH  /albums/:id               --> main.updateAlbumByID (3 handlers)
[GIN-debug] DELETE /albums/:id               --> main.deleteAlbumByID (3 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
[GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
[GIN-debug] Listening and serving HTTP on :8080

無事起動したようです(汗)


なるほど?

なんで??

docker ps をみても

k_tanaka@tanakakenyanoMacBook-Air web-service-gin % docker ps
CONTAINER ID   IMAGE            COMMAND                  CREATED          STATUS          PORTS                               NAMES
1d811899b90a   web-server-gin   "/app/main"              30 minutes ago   Up 30 minutes   0.0.0.0:8080->80/tcp                web-server-gin
d6b7ba7a6739   postgres:16      "docker-entrypoint.s…"   5 weeks ago      Up 37 hours     0.0.0.0:5432->5432/tcp              chat_db_pg
929c1c56e132   mysql:8.0        "docker-entrypoint.s…"   6 weeks ago      Up 37 hours     33060/tcp, 0.0.0.0:3307->3306/tcp   my_project_db
k_tanaka@tanakakenyanoMacBook-Air web-service-gin % 

ちゃんと起動してるが??

と思いきや、

8080 -> 80のバインディングになってるので、

localhost:8080でアクセスすると、コンテナ内の80ポートにアクセスしていますが、 Goのアプリはコンテナ内で8080で起動していますね。。。そりゃレスポンスないよな


というわけでポートを「8080:8080」で起動し直す

k_tanaka@tanakakenyanoMacBook-Air web-service-gin % docker run -p 8080:8080 --env-file .env --name web-server-gin web-server-gin
root:password@tcp(host.docker.internal:3306)/testdb?allowNativePasswords=false&checkConnLiveness=false&maxAllowedPacket=0
Connected!
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /                         --> main.home (3 handlers)
[GIN-debug] GET    /albums                   --> main.getAlbums (3 handlers)
[GIN-debug] GET    /albums/:id               --> main.getAlbumByID (3 handlers)
[GIN-debug] POST   /albums                   --> main.postAlbums (3 handlers)
[GIN-debug] PATCH  /albums/:id               --> main.updateAlbumByID (3 handlers)
[GIN-debug] DELETE /albums/:id               --> main.deleteAlbumByID (3 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://pkg.go.dev/github.com/gin-gonic/gin#readme-don-t-trust-all-proxies for details.
[GIN-debug] Environment variable PORT is undefined. Using port :8080 by default
[GIN-debug] Listening and serving HTTP on :8080

いざアクセス、、、

きました!コンテナ内で8080で起動してる時はポートバインディングも8080:8080のように設定すべきなんですね。


docker compose upで起動する

続いてdokcer compose upでDBコンテナとアプリコンテナを起動しようと思います。

まずdocker compose.yamlファイルから。

一旦以下を作ってみた。

https://www.youtube.com/watch?v=ZfUdjOAawTE

version: "3.7"
services:
  go:
    build: ./docker/go/Dockerfile
    image: golang:1.18-alpine ## コンテナの元になるイメージを指定しま
    container_name: web-server-gin ##任意のコンテナ名を指定する
    ports: #ホストのポートとコンテナのポートの対応繋ぎこみ
      - "8080:8080"
    volumes: # コンテナがアクセスできるようにする 必要がある、 ホスト上のパスか 名前付きボリュームvolume を定義
      - ./:/app
    links:
      - db
    environment:
      - DB_USER=${DB_USER}
      - DB_PASSWORD=${DB_PASSWORD}
      - DB_HOST=${DB_HOST}
      - DB_PORT=${DB_PORT}
      - DB_NAME=${DB_NAME}
  db:
    image: mysql:latest
    container_name: web-server-gin-mysql
    ports:
      - "3306:3306"
    volumes:
      - my-db:/var/lib/mysql #ここわからん
    command: mysqld --character-set-server=utf8 --collation-server=utf8_unicode_ci --skip-character-set-client-handshake #ここわからん

volumes: #永続化の設定 /var/lib/mysqlはコンテナ内でMySQLがデータを保存するパス。
  my-db: # どうなるの?→コンテナを削除しても、MySQLのデータベースの中身はmy-dbに保存され続ける。docker compose downしても消えない

以下参考

www.youtube.com

qiita.com


いざdocker compose up -d !!!

DBコンテナの方でエラーが吐かれた。

2025-07-19 03:29:49+00:00 [ERROR] [Entrypoint]: Database is uninitialized and password option is not specified
    You need to specify one of the following as an environment variable:
    - MYSQL_ROOT_PASSWORD
    - MYSQL_ALLOW_EMPTY_PASSWORD
    - MYSQL_RANDOM_ROOT_PASSWORD

なるほどなるほど。

ようわからんが、環境変数書けと怒られている。DB初期化のために必要ってことか

qiita.com


MYSQL_ROOT_PASSWORDを設定すると、設定したパスワードでのログインが可能となる。 MYSQL_ALLOW_EMPTY_PASSWORDを設定すると、パスワードなしでのログインが可能となる。 MYSQL_RANDOM_ROOT_PASSWORDを設定すると、コンテナ起動時にランダム文字列でパスワードが設定される。パスワードはログで出力される。

version: "3.7"
services:
  go:
    build: ./docker/go/Dockerfile
    image: golang:1.18-alpine ## コンテナの元になるイメージを指定しま
    container_name: web-server-gin ##任意のコンテナ名を指定する
    ports: #ホストのポートとコンテナのポートの対応繋ぎこみ
      - "8080:8080"
    volumes: # コンテナがアクセスできるようにする 必要がある、 ホスト上のパスか 名前付きボリュームvolume を定義
      - ./:/app
    links:
      - db
    environment:
      - DB_USER=${DB_USER}
      - DB_PASSWORD=${DB_PASSWORD}
      - DB_HOST=${DB_HOST}
      - DB_PORT=${DB_PORT}
      - DB_NAME=${DB_NAME}
  db:
    image: mysql:latest
    container_name: web-server-gin-mysql
    ports:
      - "3306:3306"
    volumes:
      - my-db:/var/lib/mysql
    environment:
      - MYSQL_ROOT_PASSWORD ##MySQLにおけるスーパーユーザであるrootアカウントに設定するためのパスワードを指定
      - MYSQL_ALLOW_EMPTY_PASSWORD #オプション変数。yesを設定することで、rootユーザに空のパスワードを設定してコンテナを起動することを許可
      - MYSQL_RANDOM_ROOT_PASSWORD # オプションの変数です。yesを設定することで、rootユーザのための初期パスワードを(pwgenを利用して)ランダムで生成します。生成されたパスワードは標準出力に表示されます
volumes: #永続化の設定 /var/lib/mysqlはコンテナ内でMySQLがデータを保存するパス。
  my-db: # どうなるの?→コンテナを削除しても、MySQLのデータベースの中身はmy-dbに保存され続ける。docker compose downしても消えない

上記のように修正しました。dbセクションにenvironment項目を追加です。


いざdocker compose up -d !!

2025-07-19T04:20:03.426726Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
mysqld: Table 'mysql.plugin' doesn't exist

今度は~どれどれ。 なんやこれ。


一旦ボリューム消して再度docker compose up してみる

docker-compose down -v

再度起動

CONTAINER ID   IMAGE                COMMAND                  CREATED
         STATUS         PORTS                               NAMES
c856a72ee617   golang:1.18-alpine   "/bin/sh"                6 minut
es ago   Up 6 minutes   0.0.0.0:8080->8080/tcp              web-serv
er-gin
e12582144888   mysql:latest         "docker-entrypoint.s…"   6 minut
es ago   Up 6 minutes   0.0.0.0:3306->3306/tcp, 33060/tcp   web-serv
er-gin-mysql

起動しましたね👏

とりあえずlocalhost:8080にアクセスしてみましょう

またお前か。。。

なんで?????


一旦gin のコンテナに入ってみます。。

k_tanaka@tanakakenyanoMacBook-Air web-service-gin % docker exec -it c856a72ee617 sh  
/go # ls
bin  src
/go # cd ..
/ # ls
app    bin    dev    etc    go     home   lib    media  mnt    opt    proc   root   run    sbin   srv    sys    tmp    usr    var

んん??goフォルダにはいってる???

ルート/app配下にmain.goを配置したので、そもそもアプリ起動できてないようですね。

なのでとりあえずgo/srcはいかに全部置くようにしてみます


とりあえずdocker-compose.ymlにworking_dirを追加します

version: "3.7"
services:
  go:
    build:
      dockerfile: ./docker/go/Dockerfile
      context: ./docker/go
    image: golang:1.18-alpine ## コンテナの元になるイメージを指定しま
    container_name: web-server-gin ##任意のコンテナ名を指定する
    ports: #ホストのポートとコンテナのポートの対応繋ぎこみ
      - "8080:8080"
    volumes: # コンテナがアクセスできるようにする 必要がある、 ホスト上のパスか 名前付きボリュームvolume を定義
      - ./:/go/src
    links:
      - db
    environment:
      - DB_USER=${DB_USER}
      - DB_PASSWORD=${DB_PASSWORD}
      - DB_HOST=${DB_HOST}
      - DB_PORT=${DB_PORT}
      - DB_NAME=${DB_NAME}
    working_dir: /go/src  ←追加!!
    stdin_open: true
  ・
  ・
  ・

とはやったものの、コンテナが正常に起動しますが、肝心のmainが実行されません。

はあ。。

なんで。。

調べると

Docker fileで既にimageとvolume指定してるのに

docker-compose.yaml 内で再度volumeやimage定義するのは上書きされてしまうためNGとのことだったので 以下の通りimageとvolumesをコメントアウトしました

services:
  go:
    build:
      dockerfile: ./docker/go/Dockerfile
      context: .
    # image: golang:1.18-alpine
    container_name: web-server-gin 
    ports:
      - "8080:8080"
    # volumes:    
    #   - ./:/go/src
    links:
      - db
    environment:
      - DB_USER=${DB_USER}
      - DB_PASSWORD=${DB_PASSWORD}
      - DB_HOST=${DB_HOST}
      - DB_PORT=${DB_PORT}
      - DB_NAME=${DB_NAME}
    working_dir: /go/src
    stdin_open: true
  ・
  ・
  ・
  ・

結果、docker compose up -dで2コンテナとも起動でき

localhost:8080アクセス時に「helloworld」の文字が表示されました!

DBコンテナを起動

というわけで次はDBコンテナを起動しようと思います。

  • no matching manifest for linux/arm64/v8 in the manifest list entriesエラー コンテナ起動時に出たエラー。

docker compose up db --build

当方M3Macで開発しているため、過去にもよく見たエラーだ。

k_tanaka@tanakakenyanoMacBook-Air web-service-gin % docker compose up db         
[+] Running 0/1
 ⠼ db Pulling                                                                                                                     2.4s 
no matching manifest for linux/arm64/v8 in the manifest list entries

解決方法はdocker-compose.ymlに以下を追記すればOK

db:
    image: mysql:5.7
    platform: linux/x86_64 ←これを追記
    container_name: web-server-gin-mysql
    ports:
      - "3306:3306"
    volumes:
      - my-db:/var/lib/mysql/
      - ./sql/init:/docker-entrypoint-init.d
    environment:
      - MYSQL_USER=${MYSQL_USER}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} 
      - MYSQL_ALLOW_EMPTY_PASSWORD=${MYSQL_ALLOW_EMPTY_PASSWORD} 
  • initdb.d の初期化でデータが作られない。

docker-compose.ymlでdbのサービスを以下のように記載し

  db:
    image: mysql:5.7
    platform: linux/x86_64
    container_name: web-server-gin-mysql
    ports:
      - "3306:3306"
    volumes:
      - my-db:/var/lib/mysql/
      - ./sql/init:/docker-entrypoint-initdb.d ←これ!!!
    environment:
      - MYSQL_USER=${MYSQL_USER}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} 
      - MYSQL_ALLOW_EMPTY_PASSWORD=${MYSQL_ALLOW_EMPTY_PASSWORD} 
      - MYSQL_RANDOM_ROOT_PASSWORD=${MYSQL_RANDOM_ROOT_PASSWORD}
  • ./sql/init:/docker-entrypoint-initdb.dをかき

./sql/init/init.sqlは以下

create database testdb;
user testdb

DROP TABLE IF EXISTS album;

CREATE TABLE album (
  id     INT AUTO_INCREMENT NOT NULL,
  title  VARCHAR(128) NOT NULL,
  artist VARCHAR(255) NOT NULL,
  price  DECIMAL(5,2) NOT NULL,
  PRIMARY KEY (id)
);

INSERT INTO album (title, artist, price)
VALUES
  ('Blue Train', 'John Coltrane', 56.99),
  ('Giant Steps', 'John Coltrane', 63.99),
  ('Jeru', 'Gerry Mulligan', 17.99),
  ('Sarah Vaughan', 'Sarah Vaughan', 34.98);

て書いているのに、docker compose up してDBコンテナの中身に行っても、

mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
+--------------------+
1 row in set (0.02 sec)

なんでや(泣)(泣)(泣)(泣)(泣)(泣)

と色々調べていると

MYSQL_DATABASEという環境変数を設定すればDB初期化時にCREATE DATABASEを勝手にしてくれるとのこと。。

  db:
    image: mysql:5.7
    platform: linux/x86_64
    container_name: web-server-gin-mysql
    ports:
      - "3306:3306"
    volumes:
      - my-db:/var/lib/mysql/
      - ./sql/init:/docker-entrypoint-initdb.d
    environment:
      - MYSQL_USER=${MYSQL_USER}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD}
      - MYSQL_DATABASE=${MYSQL_DATABASE} ←追加!!!

これでdocker compose upすると無事DB作られました!!

【DB】一意性制約(UNIQUE KEY)と主キー制約 (PRIMARY KEY)は違う

まず一意制約から

以下のようなテーブルを作成。

create table staff(id int , name varchar(10), age int, position varchar(10) unique);

ここで以下のSQLを実行してみる

insert into staff values(4,'鈴木', 21,'cc');

// 20:32:01  insert into staff values(4,'鈴木', 21,'cc')   Error Code: 1062. Duplicate entry 'cc' for key 'staff.position'  0.0018 sec

「duplicate entry」エラーが出ました。


では複数のカラムにuniqueキーを設定するとどうなるのでしょうか

create table staff2(id int , name varchar(10) unique, age int, position varchar(10) unique);


以下のようなテーブルを作りました、


ここで以下のようなSQLを実行します

insert into staff2 values(3,'山田', 30,'bbb');

// 20:38:06  insert into staff2 values(3,'山田', 30,'bbb') Error Code: 1062. Duplicate entry '山田' for key 'staff2.name' 0.0019 sec

エラーが出ました。

insert into staff2 values(3,'安田', 30,'bbb');
// 20:38:47  insert into staff2 values(3,'安田', 30,'bbb') Error Code: 1062. Duplicate entry 'bbb' for key 'staff2.position'    0.0021 sec


これもエラー。どうやらunique key製薬をつけたカラムは独立して重複制限かかるみたいですね。

insert into staff2 values(3,'安田', 30,'ccc');

// ok

これは無事実行できました


あれ、nameとpositionカラムの複合UNIQUEKEYにしたつもりなのだが、、それぞれ単独で一意制約がかかっていますね

CREATE TABLE staff2 (
  id INT,
  name VARCHAR(10) UNIQUE,
  age INT,
  position VARCHAR(10) UNIQUE
);

実はこの定義だと:

name は 単独で UNIQUE

position も 単独で UNIQUE

つまり、

同じ name を持つ行は1つだけ

同じ position を持つ行も1つだけ

なのでこれらは別々に独立して制約されている状態のようです。

じゃあ複合UNIQUE KEYはどうやって作るの?

CREATE TABLE staff2 (
  id INT,
  name VARCHAR(10),
  age INT,
  position VARCHAR(10),
  UNIQUE (name, position)  ←複合UNIQUE
);

上記のようにUNIQUE句に複数カラム指定すれば複合UNIQUEになるようです。

試しにINSERTしてみます

insert into staff3 values(1,'田中', 30,'aaa');
20:55:58  insert into staff3 values(3,'田中', 33,'aaa') Error Code: 1062. Duplicate entry '田中-aaa' for key 'staff3.name'    0.0013 sec

エラー発生です。しっかりエラー分にも「田中」「aaa」の2カラム分の重複が書かれていますね!

そして今回は複合UNIQUEなので、2カラムの組み合わせの単位での重複を見ています。

なので以下のようなINSERT分は実行可能です。

insert into staff3 values(3,'松丸', 33,'aaa'); //ok
insert into staff3 values(3,'田中', 33,'zzz'); //ok

ちなみに、UNIQUE制約ではNULL値は複数INSERTしても、重複としてはみなされません。いくつでもいNSERTできます。

と言うわけでUNIQUE KEY制約とは?と聞かれれば以下のように定義できるかと思います、

UNIQUE制約は、MySQLで特定のカラムやカラムの組み合わせにおいて、重複する値を禁止するための制約。 この制約を適用することで、同じ値が二度と挿入されないようにすることができる。

UNIQUE制約が利用される場面

以下のような場合にUNIQUE制約がよく使われます:

  • メールアドレスやユーザー名の登録

    • 各ユーザーが固有のメールアドレスを持つ必要がある場合。
  • 商品番号や注文番号の管理

    • 商品番号や注文番号が重複してはならない場合。
  • 複合条件の制約

    • 2つ以上のカラムを組み合わせて一意性を保証したい場合。

主キー制約(PRIMARY KEY)との違いは?

  • MySQLには、PRIMARY KEYという制約も存在しますが、UNIQUE制約とはいくつかの違いがあります。

  • PRIMARY KEYは必ずNOT NULL

    • PRIMARY KEYは一意性を保証するだけでなく、値がNULLになることも禁止します。一方、UNIQUE制約では、NULL値が許容されます。
  • PRIMARY KEYは1つのみ

    • テーブルごとにPRIMARY KEYは1つしか設定できませんが、UNIQUE制約は同じテーブル内に複数設定することが可能です。

このような違いがあります。

【DB】UPSERT

今回は実務で見かけたUPSERTについて紹介します。

最近実務の方でAPIの詳細設計をしており、そこで登録処理と更新処理を兼ねたAPのI設計を担当しました。

そこで出てきたのがタイトルの「UPSERT」。

こちら学びとして書き記しておこうと思います。

UPSERTとは?

  • INSERT文を実行する際、すでに対象のレコードが存在すれば UPDATEを実行し、なければそのままINSERT文を実行する

というものになります。

実際に試してみる(※mysql@8.0で実験)

以下のようなemployeesテーブルを作りました。

これでUPSERTをやってみましょう。

SQLは以下の通り。

insert INTO employees (id, name, department_id,hire_date, salary) 
values(14,'田中太郎',3,'2025-04-01',300000) ON DUPLICATE KEY UPDATE id=id+1;

通常のインサート文の後ろに何やらついていますね。

「ON DUPLICATE KEY UPDATE id = id +1」です。

idが14のレコードが登録されました。

INSERTされたようです、

ここでもう一度同じSQLを実行しましょう。

idが15のレコードに変更されていますね。

そういえばBULKUPSERTってのもあったな

試してみます。

調べた感じ、 BULKUPSERTは一つのレコードに対してだけでなく、複数のレコードに対して一括で実行することも可能のようです。これにより、データベース操作の効率を大幅に向上させることができま

以下のようなテーブルがあります。

ここに以下のBULKUpsert文を実行してみます。

insert INTO employees (id, name, department_id,hire_date, salary) 
values
(16,'田中太郎',3,'2025-04-01',333),
(17,'田中太郎',3,'2025-04-01',444)
as new
ON DUPLICATE KEY UPDATE 
salary = new.salary;

はい、上記のようにidの16,17がすでに存在しているため、INSERTできず、id ga16,17のレコードをsalaryのみ更新しています。

UPSERTの複数同時バージョンって感じですかね。

  • 利用例としては以下のような場合に使ったりします。

① ユーザーデータの更新/登録

INSERT INTO users (id, name, last_login)
VALUES (1, '田中', NOW())
ON DUPLICATE KEY UPDATE last_login = NOW();

→ id=1 のユーザーがいなければ新規追加、  いれば last_login を更新。

② 集計カウントなどの「存在すれば加算」

INSERT INTO access_counts (url, count)
VALUES ('/home', 1)
ON DUPLICATE KEY UPDATE count = count + 1;

→ ページが初アクセスなら追加、既にあればカウント加算。

③ キャッシュテーブルの再書き込み

INSERT INTO cache (key, value)
VALUES ('weather_tokyo', '晴れ')
ON DUPLICATE KEY UPDATE value = '晴れ';

→ キーがあれば上書き、なければ挿入。

あれば更新、なければ登録のような動きの必要な機能になりますね。

  • もしUPSERT使わないと??

✅ UPSERTを使わない場合のコード例

Optional<User> existingUser = userRepository.findById(id);

if (existingUser.isPresent()) {
    User user = existingUser.get();
    user.setLastLogin(LocalDateTime.now());
    userRepository.save(user);  // UPDATE
} else {
    User user = new User();
    user.setId(id);
    user.setName("田中");
    user.setLastLogin(LocalDateTime.now());
    userRepository.save(user);  // INSERT
}

このように:

1回目: findById で存在確認

2回目: save() で登録 or 更新

のようにクエリを2回発行しないといけなくなります。

これの 問題点・デメリットとしては、

  • SQLが2回発行される(パフォーマンス悪化の可能性)

  • トランザクション制御が必要(競合が起きやすい)

  • 並行処理が絡むとrace condition(更新競合)が起きる

と言った感じになります。



余談ですが、API詳細設計書に、DBからの取得値がテーブルごとに表で書かれており、 その表のUPSERTで更新するカラムの備考欄には「ON DUPLIACTE KEY UPDATE」と書かれていました。

いやUPSERTって上の方で書いてるしなんでわざわざ構文書くんやろと思ってましたが、

POSTGRESではUPSERTは「ON CONFLICT」って書くようですね。

なのでON DUPLIACTE KEY UPDATEとかけばmysqlだよ〜と伝えたい意図なのでしょうか。

分かりませんが、知識が増えるたびに設計書から読み取れる量が増えるのは楽しいですね。

【Go】チャネルのcloseについて整理する

以下のソースを実行すると

ch := make(chan struct{})
    close(ch)
    ch <-struct{}{}
 

エラーが起きます⇩

基本的に閉じられているチャネルには値は送信できないよう。

panic: send on closed channel

では閉じられたチャネルから受信してみる

   ch := make(chan int)
    close(ch)
    // ch <-struct{}{}
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)

以下のようにチャネルの値の型のゼロ値が帰ってくるようだ。

値が送信されていなくても、何回でも受信でき、送信されてない場合はゼロ値が入るよう。

0
0
0
0
0
0
0
0
0
0
0
0

じゃあクローズ前に値を送信してみる

    ch := make(chan int)
    ch<-1
    close(ch)
    // ch <-struct{}{}
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
 

これはデッドロックになる

fatal error: all goroutines are asleep - deadlock!

原因は容量無指定(unbuffered)のチャネルの場合は、送信する前に受信の処理を書いておく必要があるから

ch := make(chan int) // ← unbuffered
ch <- 1              // ここでブロック(受信者がいない!)
close(ch)

なので送信前に受信用のGoルーチンを書いておけなOK

    ch := make(chan int)
    go func (){
        fmt.Println(<-ch)
    }()
    ch<-1
    ch<-2
    close(ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
1
0
0
0
0
0
0
0
0
0
0
0
0


もう一つ送信増やしてみる

   ch := make(chan int)
    go func (){
        fmt.Println(<-ch)
    }()
    ch<-1
    ch<-2
    close(ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
 
1
fatal error: all goroutines are asleep - deadlock!

またデッドロック

それもそのはず。

受信処理が一つしかないから当たり前っすね。

ちなみに、

go func (){
        fmt.Println(<-ch)
    }()

ここの部分、もし ch に送られた値がまだない場合、

この goroutine はここで 待ち続け(ブロック状態)

誰かが ch <- value と 送信したタイミングで 受信が成立し、処理が再開される仕組み。

なので受信処理をもう一つ増やすと動きます

ch := make(chan int)
    go func (){
        fmt.Println(<-ch)
    }()
    go func (){
        fmt.Println(<-ch)
    }()
    ch<-1
    ch<-2
    close(ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
    fmt.Println(<-ch)
1
0
0
0
0
0
0
0
2
0
0
0
0
0

【Go】Goのチャネルとselect

selectが必要になる場面

package main

import "fmt"

func main(){
    ch1 := make(chan int ,2)
    ch2 := make(chan string ,2)

    ch2 <- "A"

    fmt.Println(1)
    v1:= <-ch1
    fmt.Println(111)
    v2:= <-ch2
    fmt.Println(v1)
    fmt.Println(v2)

上記のソースだと、どのような出力になるだろうか。 以下その出力。

k_tanaka@tanakakenyanoMacBook-Air 8 % go run main.go
1
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
        /Users/k_tanaka/Repository/Golang-Practice/practice/8/main.go:12 +0x98
exit status 2

fmt.Println(1)まで出力され、

v1:= <-ch1部分でデッドロックが発生、処理が止まってしまうのです。

原因はそもそもch1に値を何も送信しておらず、何も持っていないチャネルからひたすら受信しようとしていて、デッドロックになってしまうから。

この場合、v1:= <-ch1で処理が止まってしまい、 v2:= <-ch2の処理が実行されずに終わっている。

これはまずいと言うところで登場、selectくんです

selectは

複数のチャネル操作の中から、準備ができたものを1つ選んで実行する構文です。
非同期処理や複数のチャネルからの読み取りを効率的に制御したいときに使います。


具体的にみていきましょう。

func main(){
    ch1 := make(chan int ,2)
    ch2 := make(chan string ,2)

    ch2 <- "A"
    ch1 <-1
    ch2 <-"B"
    ch1 <-2

    select{
        case v1 := <- ch1: //ch1 に値が入ったらその処理を実行する
            fmt.Println(v1 + 1000)
        case v2 := <- ch2://ch2 に値が入ったらその処理を実行する
            fmt.Println(v2 + "!?")
    }

}

出力結果はバラバラ。

それもそのはず。

Goのselectでは、最初に成立したケースが優先されるのではなく、どのケースが実行されるかはランダム

と言う性質があるからだ。

k_tanaka@tanakakenyanoMacBook-Air 8 % go run main.go
1001
k_tanaka@tanakakenyanoMacBook-Air 8 % go run main.go
1001
k_tanaka@tanakakenyanoMacBook-Air 8 % go run main.go
A!?
k_tanaka@tanakakenyanoMacBook-Air 8 % go run main.go
1001
k_tanaka@tanakakenyanoMacBook-Air 8 % go run main.go
A!?
k_tanaka@tanakakenyanoMacBook-Air 8 % go run main.go
A!?


ちなみselect構文にもtry-catch のようなdefaultがある。

これはどちらのケースにも当てはまらない場合に呼ばれる処理。

package main

import "fmt"

func main(){
    ch1 := make(chan int ,2)
    ch2 := make(chan string ,2)

    // ch2 <- "A"
    // ch1 <-1
    // ch2 <-"B"
    // ch1 <-2

    select{
        case v1 := <- ch1: //ch1 に値が入ったらその処理を実行する
            fmt.Println(v1 + 1000)
        case v2 := <- ch2://ch2 に値が入ったらその処理を実行する
            fmt.Println(v2 + "!?")
    default:
        fmt.Println("どちらでもない")

    }

}
k_tanaka@tanakakenyanoMacBook-Air 8 % go run main.go
どちらでもない


chan 型を使ってGoroutine間でデータをやりとりしてみる

package main

import "fmt"

func main(){
    ch3 := make(chan int)
    ch4 := make(chan int)
    ch5 := make(chan int)


    go func (){
        for{
            i := <-ch3
            ch4 <- i * 2
        }
    }()

    go func (){
        for {
            i2 := <-ch4
            ch5 <- i2 -1
        }
    }()


    n:=0

    for{
        select{
            case ch3 <- n:
                    n++
            case i3 := <- ch5 :
                fmt.Println("recieved", i3)
        }

        if n > 5 {
            break
        }
    }

}


// 例えば n = 0 のときの処理の流れ
// main の select で ch3 <- 0 が成功(空いてるので送信できる)
// goroutine① が ch3 から 0 を受信し、0 * 2 = 0 を ch4 へ送信
// goroutine② が ch4 から 0 を受信し、0 - 1 = -1 を ch5 へ送信
// 次に select の case i3 := <-ch5 が実行され、main が -1 を受け取る
// received -1 が出力される

// n = 1 のときも同じ:
// main で ch3 <- 1 が送信される
// goroutine①: 1 を受信して 2 を ch4 に送る
// goroutine②: 2 を受信して 1 を ch5 に送る
// main で 1 を受信して出力 received 1

ch3,ch4,ch5のチャネルを初期化する

2つのレシーバ処理を並列処理としてGoルーチンで生成

変数nを初期化

for文がスタート

select分に入るがこの時、実行可能なcaseが1つだけであれば、それが実行される。

select {
  case ch <- val:   // 実行可能!
  case x := <-ch2:  // ブロック中
}
→ ch への送信が実行される

また、実行可能なcaseが複数ある場合はその中からランダムに1つ選ばれる

select {
  case ch1 <- val:     // 実行可能
  case ch2 <- val:     // 実行可能
}
→ ch1 または ch2 のどちらかが「ランダムに」選ばれる

そして、すべてのケースが実行不可の場合はselect自体をブロック状態にする

select {
  case x := <-ch: // ch が空で送信もない → ブロック
}
→ 何もできずに止まる(デッドロックの原因にもなる)

話を戻して元のソースで言うと、

       select{
            case ch3 <- n:
                    n++
            case i3 := <- ch5 :
                fmt.Println("recieved", i3)
        }

こちらのcase ch3 <- nが実行できるかどうかは、 「ch3が受信準備できているかどうか」つまりごルーチンで受信処理を先にかけていれば実行可能。

i3 := <-ch5 は、ch5 に値が送信されてきていれば実行可能

といった形になる