vite+Vue3 単体テストを書いてみる

WebアプリケーションのフロントエンドフレームワークとしてVue3をよく使用しています。
ただ、テストコードまでは作成できておらず、コードの品質を担保することが出来ていませんでした。
今回は、公式の Vue Test Utils にあるチュートリアルを実践してみます。

テストコードを書くより、テストを走らせるまでの準備が大変でした。

A Crash Course | Vue Test Utils (vuejs.org)

実行環境

$ node -v
v18.14.0
$ npm --version
9.4.2

プロジェクト作成

vite を使用し、Vue3のプロジェクトを作成します。

# プロジェクト作成
$ npm create vite@latest
✔ Project name: … vue-test-project
✔ Select a framework: › Vue
✔ Select a variant: › JavaScript

# 開発サーバー起動
$ cd vue-test-project
$ npm install
$ npm run dev
> vue-test-project@0.0.0 dev
> vite

  VITE v4.1.2  ready in 185 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h to show help

起動し、指定されたURLにアクセスします。

テスト用ライブラリの追加

Vueコンポーネントテスト

まずは、公式のVue Test Utils を参考にインストールします。

Installation | Vue Test Utils (vuejs.org)

$ npm install --save-dev @vue/test-utils

テストランナーで推奨されているjestをインストール

次にインストールするvue-jestに合わせるため、バージョンを固定します。

$ npm install --save-dev jest@28.1.3

jestでVueコンポーネント(.vue)をテストする場合、vue-jestというtransformerが必要という事でした。調べてもわからなかったのですが、おそらくテスト用にファイルを変換してくれるものだと思います。

こちらはVuejestのバージョンによって、パッケージとバージョンが決まっているようでした。今回はVue3 , jest@28.x なので@vue/vue3-jest@28.xをインストールしています。

GitHub - vuejs/vue-jest: Jest Vue transformer

$ npm install --save-dev @vue/vue3-jest@28

次に、テストに使用するテスト環境のためののライブラリです。 一度テストを実行したときのエラーにかかれてあったため、追加でインストールしました。 デフォルトではnodeの環境を使用するが、コンポーネントのテストをする場合はjsdomが必要なようです。

DOM 操作 · Jest

npm install --save-dev jest-environment-jsdom

ここでVueコンポーネントのテストに必要なライブラリは揃いました。

以下は不要なのですが、JavaScriptコードをテストするための準備になります。

JavaScriptテスト

jestでES6(ES2015)で書かれたJavaScriptコードをテストするためには、BabelをCommonJSに変換が必要だそうです。この変換を行うためbabel-jestをインストールします。@babel/coreはbabel-jestを使用するためにインストールしています。

babel-jest - npm

$ npm install --save-dev babel-jest@28
$ npm install --save-dev @babel/core

babelのプラグインでコードの変換する際のターゲット環境を設定できます。この環境のプリセットを設定できるライブライをインストールします。

 npm install --save-dev @babel/preset-env

Jestの設定

package.jsonにscriptsでテスト実行コマンドを追加します。 今回は/testsディレクトリ以下にテストコードを作成していくため、jest testsとしています。

{
  "scripts": {
    "test:unit": "jest tests"
  }
}

他の設定もpackage.jsonにまとめて追加します。jest.config.jsonという名前で別ファイルを準備してもいいみたいです。

  • testEnvironment: テスト環境
  • testEnvironmentOptions: テスト環境オプション
  • moduleFileExtensions: モジュールが使用するファイル拡張子
  • transform: トランスフォーマーが変換するファイルの指定
  • moduleNameMapper: 正規表現でimportされたモジュール名にマッチするものを置き換え
    • import Component from ../../src/components/HelloWorld と書いていたが src/ までのパスを@/で書くことが出来るようになります。
{
    "jest": {
        "testEnvironment": "jsdom",
        "testEnvironmentOptions": {
          "customExportConditions": [
            "node",
            "node-addons"
          ]
        },
        "moduleFileExtensions": [
          "js",
          "json",
          "vue"
        ],
        "transform": {
          ".*\\.(js)$": "babel-jest",
          ".*\\.(vue)$": "@vue/vue3-jest"
        },
        "moduleNameMapper": {
          "^@/(.*)$": "<rootDir>/src/$1"
        }
      }
}

Jestの設定 · Jest

Babelの設定

babelの設定もpackage.jsonに書いていきます。babel.config.jsという名前で別ファイルを準備してもいいみたいです。

  • presets: プリセットとして利用するプラグインのリスト
    • node: current とすることで、現在のnode.jsに合わせたコードに変換をしてくれる
{
  "babel": {
    "presets": [
      [
        "@babel/preset-env",
        {
          "targets": {
            "node": "current"
          }
        }
      ]
    ]
  }
}

Presets · Babel

これでやっとテストの準備ができました。(実際は何度もテスト実行し、足りていないライブラリや、設定を追加していきました。)

テストコード作成

ここからはチュートリアルに従って、テストコードを書いていきます。Options APIのスタイルで書かれていたが、新しいComposition APIのスタイルに変更しています。 本来はテストコードを作成し、そのテストが通るように修正していくのですが、こちらは全ての修正が完了した状態になります。

テスト対象コンポーネント

/src/components/TodoApp.vue

<script setup>
import { ref } from "vue"
const todos = ref([
    {
        id: 1,
        text: "Learn Vue.js 3",
        completed: false
    }
]);

const newTodo = ref("");

const createTodo = () => {
    todos.value.push(
        {
            id: 2,
            text: newTodo,
            completed: false
        }
    );
};
</script>
<template>
    <div>
        <div v-for="todo in todos" :key="todo.id" data-test="todo" :class="[todo.completed ? 'completed' : '']">
            {{ todo.text }}
            <input type="checkbox" v-model="todo.completed" data-test="todo-checkbox" />
        </div>

        <form data-test="form" @submit.prevent="createTodo">
            <input data-test="new-todo" v-model="newTodo" />
            <input type="submit">
        </form>
    </div>
</template>

テストコード

/tests/unit/TodoApp.spec.js

import { mount } from "@vue/test-utils"
import TodoApp from "@/components/TodoApp"

test("renders a todo", () => {
    const wrapper = mount(TodoApp);

    const todo = wrapper.get("[data-test='todo']");

    expect(todo.text()).toBe("Learn Vue.js 3");
})

test("creates a todo", async () => {
    const wrapper = mount(TodoApp);

    await wrapper.get("[data-test='new-todo']").setValue("New todo");
    await wrapper.get("[data-test='form']").trigger("submit");

    expect(wrapper.findAll("[data-test='todo']")).toHaveLength(2);
})

test("completes a todo", async () => {
    const wrapper = mount(TodoApp);

    await wrapper.get("[data-test='todo-checkbox']").setValue(true);

    expect(wrapper.get("[data-test='todo']").classes()).toContain("completed");
})

テスト実行

$ npm run test:unit

> vue-test-project@0.0.0 test:unit
> jest tests

 PASS  tests/unit/TodoApp.spec.js
  ✓ renders a todo (21 ms)
  ✓ creates a todo (8 ms)
  ✓ completes a todo (5 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        0.887 s, estimated 1 s
Ran all test suites matching /tests/i.

カバレッジ出力

簡単な見方としては

  • Stmts: C0 実行された行の網羅率
  • Branch: C1 ifなのど分岐された処理の網羅率
  • Funcs: 各関数呼び出し網羅率
  • Lines: ファイルの各実行可能行が実行されたかの網羅率。Stmts と同じ?
$ npm run test:unit -- --coverage

> vue-test-project@0.0.0 test:unit
> jest tests --coverage

[vue-jest]: Not found tsconfig.json.

 PASS  tests/unit/TodoApp.spec.js
  ✓ renders a todo (22 ms)
  ✓ creates a todo (8 ms)
  ✓ completes a todo (4 ms)

-------------|---------|----------|---------|---------|-------------------
File         | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
-------------|---------|----------|---------|---------|-------------------
All files    |     100 |      100 |     100 |     100 |                   
 TodoApp.vue |     100 |      100 |     100 |     100 |                   
-------------|---------|----------|---------|---------|-------------------
Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        0.903 s, estimated 1 s
Ran all test suites matching /tests/i.

/coverage/lcov-report/index.htmlにレポートも出力されるようです。便利。

ローカル環境 kubernetesでWebアプリ起動

kubernetesの基礎習得のため、Webアプリの起動にトライした記事になります。
goで作成したWebアプリをkubernetesクラスタで起動し、DBのデータの追加・参照できるようににしていきます。

構成図

環境

OS: Windows10 docker: 20.10.10 kind: 0.17.0

事前準備

kubernetesクラスタの準備とDBを起動します。お試しなので全てローカルで起動します。クラウドを使用した場合料金がかかるので、一旦試してみたい方はこの方法で試すと良いかなと思います。

クラスタ起動

kind を使用し、Dockerコンテナでマルチノードのクラスタを起動します。プライベートなコンテナレジストリからイメージを取得するため、認証情報(deploy token)をsecret.json経由で与えています。

kind-cluster.yaml

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: webapp-cluster
nodes:
- role: control-plane
  extraMounts:
  - containerPath: /var/lib/kubelet/config.json
    hostPath: ./secret.json
- role: worker
  extraMounts:
  - containerPath: /var/lib/kubelet/config.json
    hostPath: ./secret.json
- role: worker
  extraMounts:
  - containerPath: /var/lib/kubelet/config.json
    hostPath: ./secret.json

secret.json

{
    "auths": {
        "registry.gitlab.com": {
            "auth": "****"
        }
    }
}
$  kind create cluster --config kind-cluster.yaml

$ docker ps --format "{{.ID}}\t{{.Image}}\t{{.Names}}"
46911f618580    kindest/node:v1.25.3    webapp-cluster-control-plane
1bcddfaf552f    kindest/node:v1.25.3    webapp-cluster-worker
aac1c231d95d    kindest/node:v1.25.3    webapp-cluster-worker2

クラスタを起動すると、1つのマスタ(control-plane)と2つのワーカ(woker)コンテナが起動していることが確認できます。

DB起動

kindで起動すると3つのコンテナが作成され、kindというDockerネットワークが作成されます。このネットワーク内に、DBコンテナを起動していきます。DBはPostgres14を使用します。

$ docker network ls
NETWORK ID     NAME      DRIVER    SCOPE
d67081ee3cc5   bridge    bridge    local
d705790474c7   host      host      local
62f01985424e   kind      bridge    local ⇐ これ
0d2e79587807   none      null      local

# DBコンテナ起動
# --nat=kind ネットワーク指定
# -e POSTGRES_PASSWORD=password パスワードを環境変数で設定
$ docker run --name db  -e POSTGRES_PASSWORD=password -d --net kind postgres:14

$ docker ps --format "{{.ID}}\t{{.Image}}\t{{.Names}}"
6aaa1e918b9f    postgres:14     db
46911f618580    kindest/node:v1.25.3    webapp-cluster-control-plane
1bcddfaf552f    kindest/node:v1.25.3    webapp-cluster-worker
aac1c231d95d    kindest/node:v1.25.3    webapp-cluster-worker2

DB、テーブル作成

コンテナへ接続し、psqlコマンドで直接作成していきます。

# コンテナ接続
$ docker exec -it <コンテナID> bash
# Postgres DB接続
$ psql -U postgres

# DB作成
create database sample;
# DB切り替え
\c sample
# テーブル作成
create table users(
  id serial,
  name varchar
);

Webアプリの作成

golang echoフレームワークでuserの作成、参照を行えるエンドポイントを定義します。

  • GET: /users/:id パスで指定されたidのユーザを返す
  • POST: /users ユーザを1件追加し、追加されたユーザを返す

src/server.go

package main

import (
    "net/http"

    "github.com/labstack/echo/v4"
    "gitlab.com/test/kubernetes-web-app/src/handler"
)

func main() {
    handler.Init()

    e := echo.New()

    e.GET("/users/:id", handler.GetUser())
    e.POST("/users", handler.AddUser())

    e.Logger.Fatal(e.Start(":1323"))
}

src/handler/user.go

package handler

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

    "github.com/labstack/echo/v4"
    _ "github.com/lib/pq"
)

type User struct {
    Id   int    `json:"id"`
    Name string `json:"name"`
}

var content string
var Db *sql.DB

func Init() {
    var err error
    Db, err = sql.Open(
        "postgres",
        fmt.Sprintf(
            "host=%s port=%s user=%s password=%s dbname=sample sslmode=disable",
            os.Getenv("DB_HOST"),
            os.Getenv("DB_PORT"),
            os.Getenv("DB_USER"),
            os.Getenv("DB_PASSWORD"),
        ),
    )
    if err != nil {
        panic(err)
    }
}

func GetUser() echo.HandlerFunc {
    return func(c echo.Context) error {
        id := c.Param("id")
        user := User{}
        if err := Db.QueryRow("SELECT id, name FROM users where id = $1", id).Scan(&user.Id, &user.Name); err != nil {
            return echo.NewHTTPError(http.StatusInternalServerError, err)
        }
        return c.JSON(http.StatusOK, user)
    }
}

func AddUser() echo.HandlerFunc {
    return func(c echo.Context) error {
        name := c.FormValue("name")
        newId := 0
        if err := Db.QueryRow("INSERT INTO users(name) VALUES($1) RETURNING id", name).Scan(&newId); err != nil {
            return echo.NewHTTPError(http.StatusInternalServerError, err)
        }

        user := User{}
        if err := Db.QueryRow("SELECT id, name FROM users where id = $1", newId).Scan(&user.Id, &user.Name); err != nil {
            return echo.NewHTTPError(http.StatusInternalServerError, err)
        }
        return c.JSON(http.StatusOK, user)
    }
}

Dockerイメージの作成

作成したアプリをDockerイメージ化して、コンテナレジストリへ追加します。リポジトリへPushしたときに自動で実行されるようGitlab-runner設定と、.gitlab-ci.ymlファイルでそのコマンドを記載しています。ただこのファイルでは Dockerイメージにlatestタグしか付かず、上書きされる形になるので、ビルドイメージごとにタグを切り替えていけるよう修正が必要です。

Dockerfile

apiVersion: v1
kind: Service
metadata:
  name: webapp-service
spec:
  ports:
  - port: 1323
    targetPort: 1323
    protocol: TCP
    name: http
  selector:
    app: webapp
  type: LoadBalancer

.gitlab-ci.yml

stages:
  - build

image-build:
  stage: build
  tags:
    - docker
  image: docker:latest
  services:
    - docker:dind
  script:
    - docker info
    - docker build -t ${CI_REGISTRY}/test/kubernetes-web-app . 
    - docker tag ${CI_REGISTRY}/test/kubernetes-web-app ${CI_REGISTRY}/test4038/kubernetes-web-app:latest
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker image push --all-tags ${CI_REGISTRY}/test/kubernetes-web-app

kubernetes マニフェスト作成、起動

Webアプリをdeploymentで起動し、そのPodへのアクセスを分散するめLoadbalancerを起動します。DBの接続情報であるuser/paswordはコードに直接記載したくないので、Secretを作成しそこから取得できるようにしています。

deployment, service

apiVersion: apps/v1
kind: Deployment
metadata:
  name: webapp-deployment
  labels:
    app: webapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: webapp
  template:
    metadata:
      labels:
        app: webapp
    spec:
      containers:
      - name: webapp
        image: registry.gitlab.com/test4038/kubernetes-web-app:latest
        ports:
        - containerPort: 1323
        env:
        - name: DB_HOST
          value: db
        - name: DB_PORT
          value: "5432"
        - name: DB_USER
          valueFrom:
            secretKeyRef:
              name: webapp-secret
              key: user
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: webapp-secret
              key: password
---
apiVersion: v1
kind: Service
metadata:
  name: webapp-service
spec:
  ports:
  - port: 1323
    targetPort: 1323
    protocol: TCP
    name: http
  selector:
    app: webapp
  type: LoadBalancer

secret .datakey : value の形で複数の値を設定できます。値はbase64エンコードしてから設定する必要があります。

apiVersion: v1
kind: Secret
metadata:
  name: webapp-secret
type: Opaque
data:
  user: cG9zdGdyZXMK # postgres
  password: cGFzc3dvcmQK # epassword

マニフェストが作成できたので、kubernetesクラスタでアプリを起動していきます。

# Secret作成
$ kubectl apply -f secret.yaml

# マニフェストから起動
$ kubectl apply -f deployment.yaml
$ kubectl apply -f service.yaml

動作確認

curlコマンドでHTTPリクエストし、userの追加、参照が出来ることを確認しています。

# ポートフォワーディング
$ kubectl port-forward service/webapp-service 1323:1323

# アクセス確認
# POST user作成
$ curl -X POST localhost:1323/users -d 'name=hoge'
{"id":1,"name":"hoge"}
# GET user参照
$ curl localhost:1323/users/1
{"id":1,"name":"hoge"}

kubernetesでwebアプリケーションを起動

kubernetes の基礎的な勉強の一環で、Webアプリケーションの構築からアクセスまでを実践しています。

構成

クラスタの構築にはkindを使用し、ローカル環境でDockerコンテナを使い3つのノードを起動します。(Dockerがインストールされていること)

アプリは Go の WebFramework Echo を使用して簡単なものを作成し、Deploymentで3つのPodを起動させます。
アクセスはLoadBalancerで各Podに分散するようになっています。

コードをGitLabで管理していたため、DockerイメージはGitLabのコンテナレジストリを使用しています。

Webアプリケーション作成

まずは直接ローカルでWebアプリケーションを起動して、動作確認してみます。
GETメソッドで2つエンドポイントを定義した簡単なものです。

  • / : HelloWorld を返す
  • /users : クエリパラメータで受け取った name, email 値を返す

main.go

package main

import (
    "net/http"

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

type User struct {
    Name  string `json:"name" query:"name"`
    Email string `json:"email" query:"email"`
}

func main() {
    e := echo.New()

    e.GET("/", func(c echo.Context) error {
        return c.String(http.StatusOK, "Hello, World!")
    })

    e.GET("/users", func(c echo.Context) error {
        u := new(User)
        if err := c.Bind(u); err != nil {
            return err
        }
        return c.JSON(http.StatusOK, u)
    })

    e.Logger.Fatal(e.Start(":1323"))
}

echo.labstack.com

動作確認

2つのエンドポイントにアクセスし、レスポンスが返ってくることを確認

# アプリケーション起動
$ go run main.go

# アクセス
$ curl http://localhost:1323/
Hello, World!
$ curl http://localhost:1323/users?name=hoge&email=hoge@example.com
{"name":"hoge","email":"hoge@example.com"}

Dockerイメージ作成

kubernetesで起動させるためDockerイメージ化し、コンテナ上で起動できるか確認

Dockerfile

FROM golang:1.19.5-buster as build

WORKDIR /app
COPY go.mod ./
COPY go.sum ./
COPY server.go ./

RUN go mod download
RUN go build -o /webapp

FROM gcr.io/distroless/base-debian11

WORKDIR /

COPY --from=build /webapp /webapp

USER nonroot:nonroot

ENTRYPOINT ["/webapp"]

コンテナで起動して動作確認

同じくコンテナでもアクセスを確認

# イメージビルド
$ docker image build -t webapp .
# コンテナ起動
$ docker run -p 1323:1323 webapp

# アクセス
$ curl http://localhost:1323/
Hello, World!
$ curl http://localhost:1323/users?name=hoge&email=hoge@example.com
{"name":"hoge","email":"hoge@example.com"}

コンテナレジストリへDockerイメージのpush

GitLab Runner を使用して、パイプライン実行時にイメージのbuild,コンテナレジストリへのpush を行います
Runner は自前で起動せずGitLabのSharedRunnerを有効化し、共有されているものを使用します

.gitlab-ci.yml

stages:
  - build

image-build:
  stage: build
  tags:
    - docker
  image: docker:latest
  services:
    - docker:dind
  script:
    - docker info
    - docker build -t ${CI_REGISTRY}/kubernetes-web-app:${CI_COMMIT_SHA} . 
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker image push ${CI_REGISTRY}/kubernetes-web-app:${CI_COMMIT_SHA}

GitLab DeployTokenの発行

コンテナレジストリからDockerイメージを取得するため、DeployTokenを発行します
「Setting」⇒「Repository」⇒「Depoy tokens」

Pull an Image from a Private Registry | Kubernetes

kuberntesクラスタの起動

クラスタの定義はkind-cluster.yaml で定義し,Dockerコンテナで起動。GitLabのコンテナレジストリから、Dockerイメージを取得するためconfig.jsonをマウントしてクラスタに認証情報を与えます。

config.json

発行したDeployTokenを{username}:{password} の形式でbase64エンコードしたものをauthに使用します。
Scopeはread_registryが必要です。

# DeployToken base64エンコード
$ echo -n {username}:{password} | base64 
Z2l*******enZf

# config.jaon
{
    "auths": {
        "registry.gitlab.com": {
            "auth": "Z2l*******enZf"
        }
    }
}

kind-cluster.yaml

config.json を各ノードにマウントし、GitLabの認証情報を与えます

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: webapp-cluster
nodes:
- role: control-plane
  extraMounts:
  - containerPath: /var/lib/kubelet/config.json
    hostPath: ./secret.json
- role: worker
  extraMounts:
  - containerPath: /var/lib/kubelet/config.json
    hostPath: ./secret.json
- role: worker
  extraMounts:
  - containerPath: /var/lib/kubelet/config.json
    hostPath: ./secret.json

ノードの定義ファイルが作成できたら、クラスタを起動していきます

$ kind create cluster --config kind-cluster.yaml

Pod,Serviceリソースの作成, アクセス確認

Deployment, Service にマニフェストを作成し、クラスタ上で起動します。kindで作成されるクラスタでは、LoadBalancerを作成してもコンテナ外からアクセス出来ないため、一時的にポートフォワーディングを行いアクセスする。

deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: webapp-deployment
  labels:
    app: webapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: webapp
  template:
    metadata:
      labels:
        app: webapp
    spec:
      containers:
      - name: webapp
        image: registry.gitlab.com/kubernetes-web-app:latest
        ports:
        - containerPort: 1323

service.yaml

apiVersion: v1
kind: Service
metadata:
  name: webapp-service
spec:
  ports:
  - port: 1323
    targetPort: 1323
    protocol: TCP
    name: http
  selector:
    app: webapp
  type: LoadBalancer
# リソース作成
$ kubectl apply -f deployment.yaml
$ kubectl apply -f service.yaml

# ポートフォワーディング
$ kubectl port-forward service/webapp-service 1323:1323

# アクセス
$ curl http://localhost:1323/
Hello, World!
$ curl http://localhost:1323/users?name=hoge&email=hoge@example.com
{"name":"hoge","email":"hoge@example.com"}

SQL 縦持ち、横持ち変換

縦持ち、横持ちテーブルの相互変換方法
こちらで簡単に、SQLの確認を行えます。(paiza.io MySQL Online)

縦 ⇒ 横

使用するテーブル

id child_id name
1 1 "1-1"
1 2 "1-2"
2 1 "2-1"
2 2 "2-2"
2 3 "2-3"
3 1 "3-1"
3 2 "3-2"

SQL MAX句は、GROUP BY でグループ化した場合は集約関数を使う必要があるため使用しているだけで、関数としての意味はないです。

select 
    id,
    MAX(CASE WHEN child_id = 1 THEN name END) as child_id_1,
    MAX(CASE WHEN child_id = 2 THEN name END) as child_id_2,
    MAX(CASE WHEN child_id = 3 THEN name END) as child_id_3
FROM vertical
GROUP BY id;

実行結果

id child_id_1 child_id_2 child_id_3
1 "1-1" "1-2" NULL
2 "2-1" "2-2" "2-3"
3 "3-1" NULL NULL

横 ⇒ 縦

使用するテーブル

id child_id_1 child_id_2 child_id_3
1 1-1 1-2
2 2-1 2-2 2-3
3 3-1

SQL

SELECT * FROM (
    SELECT id, 1 as child_id, child_id_1 as name  FROM horizon
    UNION ALL
    SELECT id, 2 as child_id, child_id_2 as name le FROM horizon
    UNION ALL
    SELECT id, 3 as child_id, child_id_3 as name FROM horizon
) AS vertical
WHERE name  is NOT NULL;

実行結果

id child_id name
1 1 1-1
2 1 2-1
3 1 3-1
1 2 1-2
2 2 2-2
2 3 2-3

標準出力、標準エラー出力 をファイルに出力する

# 標準出力
$ echo hogehoge > out.txt

# 標準エラー出力
$ echoo 2> out.txt

# 標準出力 & 標準エラー出力
$ echo hogehoge > out.txt 2>&1

# ※追記したい場合
$ echo hogehoge >> out.txt 2>>&1

# ※出力を破棄する場合
$ echo hogehoge > /dev/nulll 2>&1

postgresql スタンバイサーバ の非同期レプリケーション

環境

postgresql 12.9
Docker 20.10.10

Dockerを使用し、2つのデータベースサーバを起動
ファイル単位のログシッピング、ストリーミングレプリケーションを分けて実行してみる

マスタサーバ

IPアドレス: 172.10.1.2

スタンバイサーバ

IPアドレス: 172.10.1.3

マスタサーバの起動・接続

# ボリューム作成
$ docker volume create master_db # マスタDB保存先
$ docker volume create standby_db # スタンバイDB保存先
$ docker volume create archive_wal # WALアーカイブ先

# ネットワーク作成
$ docker network create --subnet=172.10.1.0/24 sample_nw

# マスタDB コンテナ起動
$ docker run --net=sample_nw --ip=172.10.1.2 -e POSTGRES_PASSWORD=xxxxxxxx -v archive_wal:/archive_wal -v master_db:/var/lib/postgresql/data -d postgres:12.9-alpine

テストデータの追加

-- usersテーブル作成
create table users(
    id serial not null,
    name character varying
);

-- データ追加
insert into users(name) values ('A'),('B'),('C');

-- データ3件確認
postgres=# select * from users;
 id | name 
----+------
  1 | A
  2 | B
  3 | C

レプリケーションユーザ、認証情報の追加

-- ユーザ作成
postgres=# CREATE ROLE repl_user LOGIN REPLICATION RASSWORD 'xxxxxxxx';

-- 作成確認
postgres=# \du
                                   List of roles
 Role name |                         Attributes                         | Member of
-----------+------------------------------------------------------------+-----------
 postgres  | Superuser, Create role, Create DB, Replication, Bypass RLS | {}
 repl_user | Replication                                                | {}
  • pg_hba.conf に認証情報の追加。スタンバイサーバからの接続を許可する
$ vi /var/lib/postgresql/data/pg_hba.conf
# TYPE  DATABASE        USER            ADDRESS                 METHOD
host    replication     repl_user       172.10.1.3/32           md5     # 1行追加

ファイル単位のログシッピング

  • アーカイブされたWALを解読し、マスタサーバと同期を行う
  • アーカイブ先は、スタンバイサーバからアクセス出来るディスクに保存する必要がある
  • WALがアーカイブされるタイミングで同期されるため、遅延が発生する

マスタサーバ設定

## postgresql.conf

wal_level = replica # (デフォルト) 
archive_mode = on # WALアーカイブ有効化
archive_command = 'test ! -f /archive_wal/%f && cp %p /archive_wal/%f'  # アーカイブ時に実行するコマンド

スタンバイサーバを一時起動し設定

  • WALがアーカイブされているディレクトリにアクセスできるよう、archive_walを共有してマウントする
  • データベースを起動する前に、バックアップの取得や設定を行う必要があるため、bashコマンドで起動しターミナルへ接続する
$ docker run --net=sample_nw --ip=172.10.1.3 -e POSTGRES_PASSWORD=xxxxxxxx -v archive_wal:/archive_wal -v standby_db:/var/lib/postgresql/data -it postgres:12.9-alpine bash
  • pg_basebackupコマンドを使用し、マスタサーバよりバックアップ取得
# postgresユーザに切り替え
$ su postgres
# バックアップ取得
$ pg_basebackup -h 172.10.1.2 -p 5432 -U repl_user -D /var/lib/postgresql/data
  • スタンバイモードとして起動するため、standby.signalファイルを作成する
$ touch /var/lib/postgresql/data/standby.signal
  • アーカイブされたWALをコピーするため、restore_commandを設定する
## postgresql.conf

restore_command = 'cp /archive_wal/%f "%p"' # WALをアーカイブ先よりコピーする

スタンバイサーバの本起動

  • 一度コンテナを削除し、コマンドを指定せずに再度起動を行う
# コンテナID確認
$ docker ps
$ docker rm [コンテナID]

# スタンバイサーバ起動
$ docker run --net=sample_nw --ip=172.10.1.3 -e POSTGRES_PASSWORD=password -v archive_wal:/archive_wal -v standby_db:/var/lib/postgresql/data -d postgres:12.9-alpine
  • 最初に書き込んだデータが書き込まれているか確認
postgres=# select * from users;
 id | name 
----+------
  1 | A
  2 | B
  3 | C

新規データを追加しスタンバイサーバで確認

  • マスタサーバ 新規で1件レコード追加
postgres=# insert into users(name) values('D');
  • walがアーカイブされていないため、スタンバイサーバではまだ追加されていない
-- スタンバイサーバ
postgres=# select * from users;
 id | name 
----+------
  1 | A
  2 | B
  3 | C
postgres=# select pg_switch_wal();
 pg_switch_wal 
---------------
 0/A000308
  • スタンバイサーバで新規データが追加されていることを確認
postgres=# select * from users;
 id | name 
----+------
  1 | A
  2 | B
  3 | C
  4 | D  # 追加されている
  • restore_commandが常時実行されているため、WALがアーカイブされていない時は、失敗ログが出力されていた
cp: can't stat '/archive_wal/00000001000000000000000B': No such file or directory
cp: can't stat '/archive_wal/00000002.history': No such file or directory

ストリーミングレプリケーション

  • ログシッピングと違い、WALの変更分をすぐにスタンバイへ適用する。そのため、ログシッピングより最新の状態で同期出来る。

スタンバイサーバを一時起動し設定

  • ログシッピングと同様に、設定のためbashコマンドで起動しターミナルへ接続する
$ docker run --net=sample_nw --ip=172.10.1.3 -e POSTGRES_PASSWORD=password -v standby_db:/var/lib/postgresql/data -it postgres:12.9-alpine bash
  • pg_basebackupコマンドを使用し、マスタサーバよりバックアップ取得
  • --write-recovery-confオプションで、standby.signalファイルの生成や、postgresql.auto.confファイルを生成し、ストリーミングレプリケーションの接続設定を同時に行える
# postgresユーザに切り替え
$ su postgres
# バックアップ取得
$ pg_basebackup -h 172.10.1.2 -p 5432 -U repl_user -D /var/lib/postgresql/data --write-recovery-conf

# postgresql.auto.conf ファイル確認
$ cat /var/lib/postgresql/data/postgresql.auto.conf

# Do not edit this file manually!
# It will be overwritten by the ALTER SYSTEM command.
primary_conninfo = 'user=repl_user password=xxxxxxxx host=172.10.1.2 port=5432 sslmode=prefer sslcompression=0 gssencmode=prefer 
krbsrvname=postgres target_session_attrs=any'

スタンバイサーバの本起動

  • こちらも同様に、一度コンテナを削除し、コマンドを指定せずに再度起動を行う
# コンテナID確認
$ docker ps
$ docker rm [コンテナID]

# スタンバイサーバ起動
$ docker run --net=sample_nw --ip=172.10.1.3 -e POSTGRES_PASSWORD=password -v archive_wal:/archive_wal -v standby_db:/var/lib/postgresql/data -d postgres:12.9-alpine
  • 最初に書き込んだデータが書き込まれているか確認
postgres=# select * from users;
 id | name 
----+------
  1 | A
  2 | B
  3 | C

新規データを追加しスタンバイサーバで確認

  • マスタサーバ 新規で1件レコード追加
postgres=# insert into users(name) values('D');
  • ログシッピングと違い、walのアーカイブを待たずスタンバイサーバで同期されている事を確認
-- スタンバイサーバ
postgres=# select * from users;
 id | name 
----+------
  1 | A
  2 | B
  3 | C
  4 | D  # 追加されている

ストリーミングレプリケーションの確認

postgres=# select usename,client_addr,sent_lsn,write_lsn,flush_lsn,replay_lsn from pg_stat_replication;
  usename  | client_addr | sent_lsn  | write_lsn | flush_lsn | replay_lsn 
-----------+-------------+-----------+-----------+-----------+------------
 repl_user | 172.10.1.3  | 0/D000318 | 0/D000318 | 0/D000318 | 0/D000318

postgresql SQLによるバックアップ・リストア方法

f:id:wood__stock:20220121154156p:plain

SQLによるバックアップとは

  • バックアップファイルとして、SQLコマンドを生成する
  • 他のDBサーバへそのファイルを実行することで、同じ状態を再構築出来る
  • バージョン違いや、マシンアーキテクチャの違うDBサーバ間でも実行出来る

環境

postgresql 12.4

テストデータの作成

  • sampleデータベース作成
  • usersテーブル作成し、テストデータを3件追加
  • restore_sampleリストア先のデータベース作成
-- db作成
create database sample;

-- usersテーブル作成
create table users(
    id serial not null,
    name character varying
);

-- データ追加
insert into users(name) values ('A'),('B'),('C');

-- リストア先データベース作成
create database restore_sample;

データベース単体に対して実行

  • データベース毎に個別でバックアップを行う

バックアップ

$ pg_dump sample > dump_sample

リストア

  • リストア実行途中にエラーが発生した場合(ユーザが作成されていない等)でも、継続してリストアを続ける
    • 中断したい場合は--set ON_ERROR_STOP=onを設定する。
    • 中断した場合、それまで実行した不完全なデータが残る
  • --single-transactionオプションを使用し1つのトランザクションと実行することで、不完全なデータが残らないようにすることも可能
$ pg_dump restore_sample < dump_sample

# リストア途中でエラーが発生した場合、中断する
$ pg_dump --set ON_ERROR_STOP=on restore_sample < dump_sample

# 1つのトランザクションとして実行し、中断した場合でも不完全なデータを残さない
$ psql --single-transaction restore_sample < dump_sample

データベースクラスタ全体に対して実行

  • pg_dumpと違い、データベースクラスタ全体をバックアップする
  • クラスタレベルのみや、テーブル、ロールなど、分けてバックアップ可能
    • -a, --data-only: データのみ
    • -g, --globals-only: データベース以外の、クラスタレベルの情報のみ
    • -O, --no-owner: リレーションシップの省く
    • -r, --roles-only: ロールのみ
    • -s, --schema-only: スキーマのみ
    • -t, --tablespaces-only: テーブルスペースのみ スキーマ+データ

バックアップ

$ pg_dumpall > dump_all

# クラスタレベルの情報のみバックアップ
$ pg_dumpall --globals-only > dump_all

リストア

# 他サーバのデータベースに対して、リストアを実行
psql -h [ホスト名:IPアドレス] -p 5432 -f dump_all postgres

大規模データベースの場合

  • バックアップ時に出力されるファイルサイズが大きくなるため、圧縮を行う

バックアップ

  • カスタムバックアップ書式-Fを使用することで、テーブルの復元を部分的に行えるらしい(わからん)
$ pg_dump -Fc sample > dump_sample

リストア

  • カスタムバックアップ書式を使用した場合、pg_restoreコマンドを使用する必要がある
$ pg_restore -d restore_sample dump_sample

並列実行

  • 大きなデータベースの場合、バックアップ・リストアを高速で実行できる
  • 並列ジョブ数を-jで設定出来る
  • バックアップは出力フォーマット-F dとし、ディレクトリフォーマットに設定する必要がある
# 並列ジョブ3でバックアップ
pg_dump -j 3 -F d -c -f dump_sample_dir sample
# 並列ジョブ3でリストア
pg_restore -j 3 -d restore_sample dump_sample_dir