【Docker使えるようになりたい】【#19 DBコンテナ】DBコンテナを使う(DBコンテナとAPIコンテナの連携)

はじめに

今回は、これまで3記事を使って行ってきたDBコンテナについての集大成的なアプリを作って見ようと考えています。

今回作るアプリの概要

今回作るシステムは、今までの記事にしてきたDockerの機能を全部のせにしたようなものになります。

処理の流れは以下のようなイメージになります。

  1. ブラウザからGOコンテナ内のAPIサーバーにリクエスト
  2. APIサーバーが同じブリッジネットワーク上のPostgreSQLコンテナに対してSQL操作
  3. PostgreSQLコンテナから受け取ったデータをAPIサーバーがブラウザに返す

フォルダ構成

今回作るアプリの構成として、大きく分けて以下の2つのコンテナを生成します。

  • APIサーバー : Go言語
  • SQLデータベース : PostgreSQL

フォルダも各コンテナに分けて、以下のような構成のフォルダ・ファイル構造になる想定です。

とりあえずファイルの中身は空でいいので、同じフォルダ構造になるようにtodoAppの構成を作成してください。

todoApp/
├── goApi/ <- APIサーバー
│   ├── .devcontainer/
│   │    └── devcontainer.json
│   ├── Dockerfile
│   └── main.go
└── pgdb/  <- SQLデータベース
    ├── .env
    ├── Dockerfile
    └── init.sql

1. SQLコンテナの作成

まず、pgdbフォルダをルートディレクトリとして作業を開始します

また、この節で実行したコンテナはこの記事が終わるまで動かし続けるので、ボリュームを使う必要はないのですが、実際の運用を考えてマウントしておきます。

init.sqlファイルの作成

以下のER図を参考にSQLステートメントを作成していきます。

今回の本題はテーブルの構造の考え方の話ではないので、詳しい解説はしません。

SQLステートメントとして以下の内容をinit.sqlファイルを記述します。

init.sql
-- プロジェクトテーブルの作成
CREATE TABLE Projects (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    description TEXT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- タスクテーブルの作成
CREATE TABLE Tasks (
    id SERIAL PRIMARY KEY,
    project_id INT NOT NULL,
    title VARCHAR(100) NOT NULL,
    description TEXT,
    status VARCHAR(20) DEFAULT 'To Do',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (project_id) REFERENCES Projects(id)
);

これで、Dockerfileでこのinit.sqlを指定のディレクトリにコピーすれば、コンテナを始めて起動したときに自動でinit.sqlが読み込まれ、テーブルが追加されます。

Dockerfile、.envファイルの作成

Dockerfile.envファイルは前の記事で作成したpostgresql:14のものと同じで大丈夫です。

Dockerfile
FROM postgres:14

# 初期化スクリプトをコンテナ内にコピー
COPY init.sql /docker-entrypoint-initdb.d/

.env
POSTGRES_USER=user
POSTGRES_PASSWORD=pass
POSTGRES_DB=my-db

ブリッジネットワークの作成

今回の構成では、APIサーバーコンテナとの通信があるので、サーバーが通信できる範囲を限るために、ブリッジネットワークを作成して、APIサーバーSQLコンテナだけがブリッジネットワークでつながっている状態にします。

作成するブリッジネットワークの名前はdb-bridgeとします。

# コマンド
docker network create db-bridge

# 出力
c1933433bdf9aeae8d0762499d7e09fc4c321f6312f4eaedc5d762c10b61540b

これで、ブリッジネットワークの作成ができました。

コンテナ生成時(runの場合は同時に実行される)にブリッジネットワークにdb-bridgeを紐づけ忘れないように注意してください。

ボリュームの作成

データベースコンテナを消してもデータベース自体は削除されないように、データを保持しておくボリュームを作成します。

この章の最初にも話しましたが、今回はデータベースコンテナを止めないので意味はありませんが、通常は使うはずなので練習として使用します。

# コマンド
docker volume create db-volume 

# 出力
db-volume

イメージのビルド、コンテナの実行

イメージのビルド

my-pg-dbという名前でイメージをビルドします。

docker image build -t my-pg-db .

コンテナの生成と実行

pgdbという名前でコンテナを生成して実行します。
デタッチドモードで実行しているので、コマンド実行後コンソールには何も表示されませんが、裏では動いています。

docker container run --name pgdb --network db-bridge --env-file .env -v db-volume:/var/lib/postgresql/data --rm -d my-pg-db

確認

実行中のサーバーコンテナにpsqlを使って接続

こうすることで、psqlを終了させても、コンテナ自体のプロセスに影響を出さないようにしています。

docker container exec -it pgdb psql -U user -d my-db

テーブルの確認

\d テーブル名コマンドを実行して以下のような出力が出れば確認終了です。

# コマンド
my-db-# \d projects
# 出力
 id          | integer                     |           | not null | nextval('projects_id_seq'::regclass)
 name        | character varying(100)      |           | not null | 
 description | text                        |           |          | 
 created_at  | timestamp without time zone |           |          | CURRENT_TIMESTAMP
 updated_at  | timestamp without time zone |           |          | CURRENT_TIMESTAMP

# コマンド
my-db-# \d tasks
# 出力
 id          | integer                     |           | not null | nextval('tasks_id_seq'::regclass)
 project_id  | integer                     |           | not null | 
 title       | character varying(100)      |           | not null | 
 description | text                        |           |          | 
 status      | character varying(20)       |           |          | 'To Do'::character varying
 created_at  | timestamp without time zone |           |          | CURRENT_TIMESTAMP
 updated_at  | timestamp without time zone |           |          | CURRENT_TIMESTAMP

GO APIコンテナの作成

次は、goApiをカレントディレクトリにしてください。

APIサーバーのコードは、DevConainerに接続してから記述していきます。

今回はGoの開発環境をホストOSにインストールしなくてもいいようにDevContainerを使って環境&実行環境を構築しようと思います。

開発環境なので実際にサービスとしてリリースする場合は、マルチステージビルドなどを使いシンプルな環境で、ビルドしたバイナリだけを動かすコンテナを用意してください

devcontainer.jsonの作成

まずは、DevContainerを使うために必要なdevcontainer.jsonを記述していきます。

コンテナが接続するブリッジネットワークを指定したいので、runArgsで実行時オプションを指定する必要があります。

devcontainer.json
{
  "name": "Go API Dev",
  "build":  {
    "dockerfile": "../Dockerfile"
  },
  "customizations": {
    "vscode": {
      "extensions": [
        "golang.go"
      ]
    }
  },
  "runArgs": [
    "--network=db-bridge"  // ブリッジネットワークを指定
  ]
}

ホストOSの外からもアクセスしたい場合は、以下の記述をrunArgsに追加して、ポートバインディングを明示的に行ってください。

devcontainer.json
{
  // === 省略 ===
  "runArgs": [
    "--network=db-bridge",
    "-p",       // 追加
    "8080:8080" // 追加
  ]
}

Dockerfileの作成

DockerfileにはFROMしか記述していません。
とりあえず拡張性を持たせるために使っています。

Dockerfile
FROM golang:1.23.2

Dev Containerで接続

goApiディレクトリでDevContainerを使ってコンテナを実行接続します。

接続方法がわからない方は、以下の記事を参考にしてみてください

Go プロジェクトの構築(DevContainer内)

これ移行GoAPIプロジェクトでは、DevContainer内のターミナルでコマンドを実行しています。
(ホストOS側のターミナルを使ってもうまく動きません)

まずは状態を確認するために、VSCodeのターミナルを立ち上げて、pwdコマンドを実行してディレクトリを表示してみてくだいさい。

以下のようなパス出力が出れば問題ありません。

# コマンド
pwd 
# 出力
/workspaces/goApi

使用するライブラリの導入

Goのライブラリ管理用のツールgomodを初期化します。

以下のコマンドを実行すると、goApiディレクトリ内にgo.modというファイルが生成されます。

go mod init goApi

PostgreSQLに接続するために使用するpqというGoのライブラリをインストールします。

ライブラリのインストールにはgo.modファイルがディレクトリにある状態で、go getコマンドを使います。

このコマンド実行時に初めてライブラリを導入したので、新しくgo.sumというファイルが生成されると思います。
これ移行はこのファイルにライブラリの情報が追記されていきます。

go get -u github.com/lib/pq

サーバーとしてルーティングを楽に記述するために使用するmuxをインストールします。

go get -u github.com/gorilla/mux

main.goを作成(DevContainer内)

ではここから、Go言語を使った簡単なAPIサーバーを作ります。

とりあえず、main.goに以下の内容を記述します。
細かい処理は今回の解説の範囲外になりますが、GoでAPIサーバーを作る解説はまた別の記事で行う予定です。

とりあえず今はコピペしてもらってエラーが出なければOKです。

main.go
package main

import (
    "database/sql"
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "strconv"

    "github.com/gorilla/mux"
    _ "github.com/lib/pq"
)

const (
    host     = "pgdb"
    port     = 5432
    user     = "user"
    password = "pass"
    dbname   = "my-db"
)

var db *sql.DB

type Project struct {
    ID          int    `json:"id"`
    Name        string `json:"name"`
    Description string `json:"description"`
}

type Task struct {
    ID          int    `json:"id"`
    ProjectID   int    `json:"project_id"`
    Title       string `json:"title"`
    Description string `json:"description"`
    Status      string `json:"status"`
}

func main() {
    initDB()

    r := mux.NewRouter()

    // ルート定義
    r.HandleFunc("/projects", getProjects).Methods("GET")
    r.HandleFunc("/projects", createProject).Methods("POST")
    r.HandleFunc("/projects/{id:[0-9]+}", updateProject).Methods("PUT")
    r.HandleFunc("/projects/{id:[0-9]+}", deleteProject).Methods("DELETE")

    r.HandleFunc("/tasks", getTasks).Methods("GET")
    r.HandleFunc("/tasks", createTask).Methods("POST")
    r.HandleFunc("/tasks/{id:[0-9]+}", updateTask).Methods("PUT")
    r.HandleFunc("/tasks/{id:[0-9]+}", deleteTask).Methods("DELETE")

    // サーバーを8080ポートで開始
    log.Fatal(http.ListenAndServe("0.0.0.0:8080", r))
}

func initDB() {
    psqlInfo := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
        host, port, user, password, dbname)

    var err error
    db, err = sql.Open("postgres", psqlInfo)
    if err != nil {
        log.Fatalf("Failed to open DB connection: %v", err)
    }

    if err = db.Ping(); err != nil {
        log.Fatalf("Failed to ping DB: %v", err)
    }
    fmt.Println("Connected to the database!")
}

// ---- CRUD for Projects ----

func getProjects(w http.ResponseWriter, r *http.Request) {
    rows, err := db.Query("SELECT id, name, description FROM Projects")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer rows.Close()

    var projects []Project
    for rows.Next() {
        var project Project
        if err := rows.Scan(&project.ID, &project.Name, &project.Description); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        projects = append(projects, project)
    }
    json.NewEncoder(w).Encode(projects)
}

func createProject(w http.ResponseWriter, r *http.Request) {
    var project Project
    if err := json.NewDecoder(r.Body).Decode(&project); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    err := db.QueryRow("INSERT INTO Projects (name, description) VALUES ($1, $2) RETURNING id",
        project.Name, project.Description).Scan(&project.ID)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(project)
}

func updateProject(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id, _ := strconv.Atoi(vars["id"])

    var project Project
    if err := json.NewDecoder(r.Body).Decode(&project); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    _, err := db.Exec("UPDATE Projects SET name = $1, description = $2 WHERE id = $3",
        project.Name, project.Description, id)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

func deleteProject(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id, _ := strconv.Atoi(vars["id"])

    _, err := db.Exec("DELETE FROM Projects WHERE id = $1", id)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

// ---- CRUD for Tasks ----

func getTasks(w http.ResponseWriter, r *http.Request) {
    projectID := r.URL.Query().Get("project_id")
    if projectID == "" {
        http.Error(w, "project_id is required", http.StatusBadRequest)
        return
    }

    rows, err := db.Query("SELECT id, project_id, title, description, status FROM Tasks WHERE project_id = $1", projectID)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer rows.Close()

    var tasks []Task
    for rows.Next() {
        var task Task
        if err := rows.Scan(&task.ID, &task.ProjectID, &task.Title, &task.Description, &task.Status); err != nil {
            http.Error(w, err.Error(), http.StatusInternalServerError)
            return
        }
        tasks = append(tasks, task)
    }
    json.NewEncoder(w).Encode(tasks)
}

func createTask(w http.ResponseWriter, r *http.Request) {
    var task Task
    if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    err := db.QueryRow("INSERT INTO Tasks (project_id, title, description, status) VALUES ($1, $2, $3, $4) RETURNING id",
        task.ProjectID, task.Title, task.Description, task.Status).Scan(&task.ID)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(task)
}

func updateTask(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id, _ := strconv.Atoi(vars["id"])

    var task Task
    if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    _, err := db.Exec("UPDATE Tasks SET title = $1, description = $2, status = $3 WHERE id = $4",
        task.Title, task.Description, task.Status, id)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

func deleteTask(w http.ResponseWriter, r *http.Request) {
    vars := mux.Vars(r)
    id, _ := strconv.Atoi(vars["id"])

    _, err := db.Exec("DELETE FROM Tasks WHERE id = $1", id)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

実行(DevContainer内)

最後にApiサーバーを起動します。

DevContaienr内のターミナルで以下のコマンドを実行してください。

出力としてConnected to the database!と出力されれば正常に動作しています。

# コマンド
go run main.go
# 出力
Connected to the database!

もし、Failed to ping DB: dial tcp: lookup pgdb on 127.0.0.11:53: no such host exit status 1このようなエラーが表示されてしまった場合は、データベースへの接続に失敗しているので、DBコンテナが起動しているか確認してください。

検証

Postmanやその他のツールを使って、REST APIを使う感覚で以下の例のようなリクエストを送ってみてください

以下のコードの例は、各エンドポイントに対するリクエスト例をcurlを使って行う例になります。

プロジェクト関連のリクエスト

GET /projects(すべてのプロジェクトを取得)

curl -X GET http://localhost:8080/projects
  • レスポンス例:
[
    {
        "id": 1,
        "name": "First Project",
        "description": "This is the first project"
    },
    {
        "id": 2,
        "name": "Second Project",
        "description": "This is the second project"
    }
]

POST /projects(新しいプロジェクトを追加)

curl -X POST http://localhost:8080/projects -H "Content-Type: application/json" -d '{"name":"New Project","description":"Project description"}'
  • リクエストボディ:
{
    "name": "New Project",
    "description": "Project description"
}
  • レスポンス例:
{
    "id": 3,
    "name": "New Project",
    "description": "Project description"
}

PUT /projects/{id}(プロジェクトを更新)

curl -X PUT http://localhost:8080/projects/1 -H "Content-Type: application/json" -d '{"name":"Updated Project","description":"Updated description"}'
  • リクエストボディ:
{
    "name": "Updated Project",
    "description": "Updated description"
}
  • レスポンス: ステータスコード 204(成功時、内容なし)

DELETE /projects/{id}(プロジェクトを削除)

curl -X DELETE http://localhost:8080/projects/1
  • レスポンス: ステータスコード 204(成功時、内容なし)

タスク関連のリクエスト

GET /tasks?project_id={id}(特定のプロジェクトに属するタスクを取得)

curl -X GET "http://localhost:8080/tasks?project_id=1"
  • レスポンス例:
[
    {
        "id": 1,
        "project_id": 1,
        "title": "First Task",
        "description": "This is the first task",
        "status": "To Do"
    },
    {
        "id": 2,
        "project_id": 1,
        "title": "Second Task",
        "description": "This is the second task",
        "status": "In Progress"
    }
]

POST /tasks(新しいタスクを追加)

curl -X POST http://localhost:8080/tasks -H "Content-Type: application/json" -d '{"project_id":1,"title":"New Task","description":"New task description","status":"To Do"}'
  • リクエストボディ:
{
    "project_id": 1,
    "title": "New Task",
    "description": "New task description",
    "status": "To Do"
}
  • レスポンス例:
{
    "id": 3,
    "project_id": 1,
    "title": "New Task",
    "description": "New task description",
    "status": "To Do"
}

PUT /tasks/{id}(タスクを更新)

curl -X PUT http://localhost:8080/tasks/1 -H "Content-Type: application/json" -d '{"title":"Updated Task","description":"Updated description","status":"Done"}'
  • リクエストボディ:
{
    "title": "Updated Task",
    "description": "Updated description",
    "status": "Done"
}
  • レスポンス: ステータスコード 204(成功時、内容なし)

DELETE /tasks/{id}(タスクを削除)

curl -X DELETE http://localhost:8080/tasks/1
  • レスポンス: ステータスコード 204(成功時、内容なし)

さいごに

今回は、サーバーとデータベースをつなげて一通りの動作ができるアプリを作成しました。
次回はこの作業を更に簡単にできるDocker Composeについてまとめていこうと考えています。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA