GitHub Actions でローカルの composite を使う場合

GitHub Actions でローカルにある composite を使う場合は、あらかじめチェックアウトしていないと、ファイルが見つからないとエラーが出てしまうので注意する。

NG:

name: hoge
on:
  push:
jobs:
  hoge:
    name: Hoge
    runs-on: ubuntu-latest
    steps:
      - uses: ./.github/composite/fuga

OK:

name: hoge
on:
  push:
jobs:
  hoge:
    name: Hoge
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - uses: ./.github/composite/fuga

CIでGCPのServiceAccountを使って認証する方法

GCPでServiceAccountで認証するときはGOOGLE_APPLICATION_CREDENTIALS環境変数に鍵ファイルのパスを設定するが、CI実行時にはどうするんだろうと考えた結果、鍵ファイルをbase64エンコードしたものをCIの環境変数に設定して、CI実行時にデコードしてGOOGLE_APPLICATION_CREDENTIALS環境変数に設定されているパスに出力とかしたけど、もっといい方法ないのかな。

# 1. 鍵ファイルをbase64でエンコード
cat /path/to/key.json | base64

# 2. 以下をCIの環境変数に設定
#   GOOGLE_APPLICATION_CREDENTIALS_CONTENT 1.でエンコードした文字列
#   GOOGLE_APPLICATION_CREDENTIALS /path/to/key.json

# 3. base64でエンコードされた鍵ファイルをデコードして、`GOOGLE_APPLICATION_CREDENTIALS` のパスに出力
echo $GOOGLE_APPLICATION_CREDENTIALS_CONTENT | base64 -d > $GOOGLE_APPLICATION_CREDENTIALS

Docker で Compose V2 などをキャッチアップした

docker-compose up ではなく docker compose up を使うように推奨されていることは知っていたが、ちゃんとキャッチアップしてなかったのでした。また、その過程で Compose V2 以外にも新たに知ったことがあったのでここに記す。

docker-compose との違い

大雑把には以下が異なる。

  • docker-compose は docker とは別のスタンドアローンバイナリだったが、docker compose は docker cli plugin として提供される
  • docker compose は Compose Spec に準拠するようになった

Compose Spec

Composeファイルの標準仕様で、プラットフォームやクラウドに依存しない仕様として策定されている模様。

Compose Spec V2 では従来から主に以下が変更/追加されている。

  1. Composeファイルは docker-compose.yml から compose.yaml になった
  2. トップレベルの version は非推奨になった
  3. 設定ファイルのマウントを行う configs が追加された
  4. 秘匿情報のマウントを行う secrets が追加された
  5. 設定の拡張を行う extends が追加された

1. 仕様ファイルは docker-compose.yml から compose.yaml になった

そのまま。後方互換性のため docker-compose.yml も使えるが、今後標準は compose.yaml となる。なお、Compose Spec では compose.yml も使えるが、拡張子は yaml のほうが好ましいとのこと。

2. トップレベルの version は非推奨になった

今までは、トップレベルの version でComposeファイルを検証していたが、これが今後非推奨となるので記述しなくなる模様。

3. 設定ファイルのマウントを行う configs が追加された

今までは、volumes で行っていたが、今後は設定ファイルの類は configs を使用する。docker compose を使っている限りだと volumes と特に大差はないようだが、プラットフォームごとに実装方法を変えられるように独立した模様。

compose.yaml:

services:
  db:
    image: mysql:8
    configs:
      - source: mysql-config
        target: /etc/mysql/conf.d/my.cnf
    ...

configs:
  mysql-config:
    file: ./my.cnf

4. 秘匿情報のマウントを行う secrets が追加された

秘匿情報のマウントが行える secrets が追加された。configs とは実装が異なる可能性があるので、独立している模様。

compose.yaml:

services:
  app:
    image: golang:1.20.1
    secrets:
      - source: app-secrets
        target: /app/.env
    ...

secrets:
  app-secrets:
    file: ./app-secrets.env

5. 設定の拡張を行う extends が追加された

他のserviceの設定をマージ/拡張できるようになる extends が追加された。同一ファイルのみならず、別ファイルも参照できる模様。

compose.common.yaml:

services:
  common:
    init: true
    environment:
      TZ: utc

compose.yaml:

services:
  app:
    image: golang:1.20.1
    extends:
      file: compose.common.yaml
      service: common
    ...

  db:
    image: mysql:8
    extends:
      file: compose.common.yaml
      service: common
    ...

Compose V2 以外のアレコレ

mysqladmin ping を使って、DBコンテナのMySQLに接続できる状態になってから、アプリケーションコンテナを起動する

depends_on を使えば、コンテナ間の依存関係を定義できるので、DBコンテナが起動してからアプリケーションコンテナを起動するのができたし、今までもこれを使ってた。

compose.yaml:

services:
  app:
    image: golang:1.20.1
    depends_on:
      - db
    ...

  db:
    image: mysql:8
    ...

しかし、これだとdbコンテナが起動したあとに対象コンテナを起動するという指定にすぎないので、dbコンテナが起動しているがMySQLにはまだ接続できない状態には対処できない。その場合はdbコンテナの healthcheckmysqladmin ping で疎通確認し、依存先コンテナの depends_oncondition: service_healthy を指定すればできる。

compose.yaml:

services:
  app:
    image: golang:1.20.1
    depends_on:
      db:
        condition: service_healthy
    ...

  db:
    image: mysql:8
    healthcheck:
      test: ["CMD", "mysqladmin" ,"ping", "-h", "0.0.0.0"]
      interval: 1s
      timeout: 1s
      retries: 30
      start_period: 1s
    ...

docker compose で作成されるコンテナ名などのプレフィックスを変更する

docker compose で作成されるコンテナ名などのプレフィックスは、Composeファイルのトップレベルの name によって指定できる。また、COMPOSE_PROJECT_NAME環境変数でこの指定はオーバーライドできる。

compose.yaml:

name: docker-compose-v2
services:
  ...
$ docker compose up
[+] Running 4/3
 ⠿ Network docker-compose-v2_default  Created                                                                                                                                     0.0s
 ⠿ Volume "docker-compose-v2_db"      Created                                                                                                                                     0.0s
 ⠿ Container docker-compose-v2-db-1   Created                                                                                                                                     0.1s
 ⠿ Container docker-compose-v2-app-1  Created                                                                                                                                     0.1s
...
$ COMPOSE_PROJECT_NAME=other-name docker compose up
[+] Running 4/3
 ⠿ Network other-name_default  Created                                                                                                                                            0.0s
 ⠿ Volume "other-name_db"      Created                                                                                                                                            0.0s
 ⠿ Container other-name-db-1   Created                                                                                                                                            0.1s
 ⠿ Container other-name-app-1  Created                                                                                                                                            0.1s
...

なお、Composeファイルでは環境変数で設定値の指定ができるが、nameでは使用できない模様。

compose.yaml:

name: ${BASE_NAME}-docker-compose-v2
services:
  ...
$ BASE_NAME=hoge docker compose up
[+] Running 4/4
 ⠿ Network base_name-docker-compose-v2_default  Created                                                                                                                           0.1s
 ⠿ Volume "base_name-docker-compose-v2_db"      Created                                                                                                                           0.0s
 ⠿ Container base_name-docker-compose-v2-db-1   Created                                                                                                                           0.1s
 ⠿ Container base_name-docker-compose-v2-app-1  Created                                                                                                                           0.6s
...

環境

  • OS: macOS Ventura 13.2
  • Docker Desktop: 4.16.2
  • Docker Engine: 20.10.22
  • Docker Compose: 2.15.1

参考

Compose V2

その他

サンプルコード

この記事で使用したサンプルコードは以下にある。

github.com/mrk21/sandbox/docker-compose-v2

gomockのMatcherで構造体を再帰的に比較する

gomockの引数のMatcherでgomock.Eq()を使うときに、構造体がポインタ型だったりするとうまく比較できない。

type Hoge struct {
    ID   string
}

m.EXPECT().Hoge(gomock.Eq(&Hoge{ID: "1"})).Return(nil).AnyTimes() // マッチしない
m.Hoge(&Hoge{ID: "1"})

そのため、go-cmp を使って両者を比較するカスタムMatcherを実装する。

func GomockDeepEq(expected interface{}) gomock.Matcher {
    return &gomockDeepEqMatcher{
        expected: expected,
    }
}

type gomockDeepEqMatcher struct {
    expected interface{}
    diff     string
}

func (m *gomockDeepEqMatcher) Matches(x interface{}) bool {
    m.diff = cmp.Diff(x, m.expected, CmpTransformJSON())
    return m.diff == ""
}

func (m *gomockDeepEqMatcher) String() string {
    return m.diff
}

// interface check
var _ gomock.Matcher = (*gomockDeepEqMatcher)(nil)
type Hoge struct {
    ID   string
}

m.EXPECT().Hoge(GomockDeepEq(&Hoge{ID: "1"})).Return(nil).AnyTimes() // マッチする
m.Hoge(&Hoge{ID: "1"})

ここで、 go-cmp の cmp.Diff() でJSON文字列を比較できるようにする - mrk21::blog {} で述べたように、JSON文字列を比較できるようにしておくとさらに便利になる。

type Hoge struct {
    ID   string
    JSON json.RawMessage
}

m.EXPECT().Hoge(GomockDeepEq(&Hoge{ID: "1", JSON: json.RawMessage(`{"a":1, "b":2}`)})).Return(nil).AnyTimes() // マッチする
m.Hoge(&Hoge{ID: "1", JSON: json.RawMessage(`{"b":2, "a":1}`)})

環境

  • Go: 1.9.3
  • gomock: 1.4.4
  • go-cmp: 0.5.9

go-cmp の cmp.Diff() でJSON文字列を比較できるようにする

go-cmp を使うと2つの構造体のDiffを取ることができるが、プロパティにJSON文字列([]bytejson.RawMessage など)があると、内容は同じでもインデント等が異なるとdiffが出てしまい、うまく比較できない。

type Hoge struct {
    ID   string
    JSON json.RawMessage
}

func main() {
a := Hoge{ID: "1", JSON: json.RawMessage(`{"a":1, "b":2}`)}
b := Hoge{ID: "1", JSON: json.RawMessage(`{"b":2, "a":1}`)}
diff := cmp.Diff(a, b)
fmt.Println(diff) // diff がでる

この場合は、cmp.Transformer()JSON文字列をjson.Unmarshal()して構造体に変換するようにして、cmp.Diff()のオプションに渡すことで比較できるようになる1

func CmpTransformJSON() cmp.Option {
    return cmp.FilterValues(
        func(x, y []byte) bool {
            return json.Valid(x) && json.Valid(y)
        },
        cmp.Transformer("ParseJSON", func(in []byte) (out interface{}) {
            if err := json.Unmarshal(in, &out); err != nil {
                panic(err)
            }
            return out
        }),
    )
}
type Hoge struct {
    ID   string
    JSON json.RawMessage
}

a := Hoge{ID: "1", JSON: json.RawMessage(`{"a":1, "b":2}`)}
b := Hoge{ID: "1", JSON: json.RawMessage(`{"b":2, "a":1}`)}
diff := cmp.Diff(a, b, CmpTransformJSON())
fmt.Println(diff) // diff がでない

まとめると以下のようになる。

package main

import (
    "encoding/json"
    "fmt"

    "github.com/google/go-cmp/cmp"
)

type Hoge struct {
    ID   string
    JSON json.RawMessage
}

func main() {
    a := Hoge{ID: "1", JSON: json.RawMessage(`{"a":1, "b":2}`)}
    b := Hoge{ID: "1", JSON: json.RawMessage(`{"b":2, "a":1}`)}
    {
        diff := cmp.Diff(a, b)
        fmt.Println("diff:", diff) // diff がでる
    }
    {
        diff := cmp.Diff(a, b, CmpTransformJSON())
        fmt.Println("diff:", diff) // diff がでない
    }
}

func CmpTransformJSON() cmp.Option {
    return cmp.FilterValues(
        func(x, y []byte) bool {
            return json.Valid(x) && json.Valid(y)
        },
        cmp.Transformer("ParseJSON", func(in []byte) (out interface{}) {
            if err := json.Unmarshal(in, &out); err != nil {
                panic(err)
            }
            return out
        }),
    )
}

Go Playground - The Go Programming Language

環境

  • Go: 1.9.3
  • go-cmp: 0.5.9

Docker container からホストでListenしているポートにアクセスする

Docker Desktop ではコンテナから host.docker.internal を使って、ホストにアクセスすることができる。

index.js:

const express = require("express");
const app = express();
const port = 3000;

app.get("/", (req, res) => {
  res.send("OK\n");
});

app.listen(port, () => {
  console.log("Start");
});
$ node index.js
$ docker run -it --rm nginx curl http://host.docker.internal:3000
OK

だが、これはあくまでも Docker Desktop が提供する機能なので、それ以外のDocker環境では使用できない。また Docker Desktop でも WSL2 backend のものでは使用できないようだ。

しかし、Docker Engine 10.20.0 から --add-host="host.docker.internal:host-gateway" オプションを指定することで、同様のことができるようになった1。このオプションは、コンテナの /etc/hosts に docker network上でのホストのIPを host.docker.internal として登録する。ここで、オプションとしては :host-gatewayの部分が重要で、host.docker.internalの部分は任意の値にできる。

$ node index.js
$ docker run -it --rm --add-host="host.docker.internal:host-gateway" nginx curl http://host.docker.internal:3000
OK
$ docker run -it --rm --add-host="host:host-gateway" nginx curl http://host:3000
OK

また、--add-host オプションは docker-compose.yml では extra_hosts で指定できる。

---
version: '3.8'
services:
  service1:
    image: image-name
    extra_hosts:
      - host.docker.internal:host-gateway

ユースケース

これを用いると、たとえば以下のようなことができるようになる。

  • ローカルでポート3000にListenしている Next server をSSL化する
  • k8sクラスタで起動しているMySQLに、devcontainerのコンテナからアクセスする

ローカルでポート3000にListenしている Next server をSSL化する

今日の開発環境では広く用いられている Docker だが、フロントエンド開発では Docker を使わないことが多い。これは Docker 環境を用意する必要性があまりないことと、主に Docker Desktop for Mac のホストディレクトリをマウントしたときのIO性能が低いためである(WebpackなどはIOヘビー)。

しかし、たとえばモバイル向けのSPAで写真撮影のためにカメラデバイスを使いたいといった場合、PCで起動している Next server にモバイル端末がアクセスするためにはPCの Private IP を使って(たとえば 192.168.1.2)、http://192.168.1.2:3000 にアクセスする必要があるが、カメラデバイスにアクセスするためには localhost や fileスキームでアクセスした場合を除き、SSLで保護されている必要がある2

ここで、SSL化するのに手っ取り早いのは mkcert でCAインストール/証明書を作成して、Docker上にnginxを立ててSSLを有効にしたserverから Next server にリバースプロキシしてやることだが、上で述べたように、フロントエンド環境をDocker上に構築したくはない。

そこで、host.docker.internal を使って、nginx では http://host.docker.internal:3000/ にリバースプロキシしてやることで実現する。

docker-compose.yml:

version: '3.7'
services:
  nginx:
    image: nginx
    ports:
      - 0.0.0.0:80:80
      - 0.0.0.0:443:443
    volumes:
      - type: bind
        source: ./default.conf
        target: /etc/nginx/conf.d/default.conf
      - type: bind
        source: ./certs
        target: /etc/certs
    extra_hosts:
      - host.docker.internal:host-gateway

default.conf:

server {
    listen 443 ssl default;
    ssl_certificate /etc/certs/localhost+2.pem;
    ssl_certificate_key /etc/certs/localhost+2-key.pem;

    proxy_set_header  Host                $host;
    proxy_set_header  X-Real-IP           $remote_addr;
    proxy_set_header  X-Forwarded-Host    $host;
    proxy_set_header  X-Forwarded-Server  $host;
    proxy_set_header  X-Forwarded-For     $proxy_add_x_forwarded_for;

    location / {
        proxy_pass http://host.docker.internal:3000/;
    }
}

server {
    listen 80;
    return 301 https://$host$request_uri;
}
$ mkcert -install
$ (cd certs && mkcert localhost 127.0.0.1 192.168.1.2)
$ node index.js
$ docker-compose up
$ curl https://192.168.1.2/
OK

なお、モバイル端末にも mkcert のCAをインストールする必要があるため、たとえばiOS端末の場合、以下の手順を踏む。

  1. キーチェーンアクセスを起動し、mkcertでインストールしたCAを右クリックして、PEM形式で書き出す
  2. 書き出したPEMをAir DropなどでiOS端末に送信し、インストールする
  3. [設定] → [一般] → [情報] → [証明書信頼設定] → ルート証明書を全面的に信頼する と進み、インストールした証明書をONする

また、SSLは使っていないが、これに近いサンプルは sandbox/docker-host-docker-internal にある。

k8sクラスタで起動しているMySQLに、devcontainerのコンテナからアクセスする

以下のような開発開発があったとする。

  • 開発開発がk8sクラスタ
  • 開発対象のDockerイメージを使ってdevcontainer用のdocker-composeを立ち上げ、VS Code を使って開発

このような場合、普通はMySQLなどのDBもdevcontainer用docker-compose上に用意するか、DBアクセスはあきらめる必要があるが、host.docker.internal と Kube Forwarder などをつかって k8s上の MySQL service をホストにポートフォワーディングすることで、devcontainer から k8s 上のMySQLにアクセスすることができる。

.devcontainer/devcontainer.json:

{
    "name": "go-app",
    "dockerComposeFile": [
        "../docker-compose.yml"
    ],
    "service": "app",
    "remoteUser": "root",
    "workspaceFolder": "/app",
    "extensions": [
        "golang.go"
    ]
}

Dockerfile:

FROM golang:1.18.2
RUN go install golang.org/x/tools/gopls@latest
WORKDIR /app

docker-compose.yml:

version: '3.8'
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    command: sleep infinity
    volumes:
      - type: bind
        source: .
        target: /app
    environment:
      DB_HOST: host.docker.internal
    extra_hosts:
      - host.docker.internal:host-gateway

main.go:

import (
    "fmt"
    "database/sql"

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

func main() {
    c := mysql.Config{
        DBName: "dbname",
        User:   "root",
        Passwd: "",
        Addr:   fmt.Sprintf("%s:3306", os.Getenv("DB_HOST")),
        Net:    "tcp",
    }
    dsn := c.FormatDSN()
    db, _ := sql.Open("mysql", dsn)
    var value string
    db.QueryRow("SELECT 1").Scan(&value)
    fmt.Println(value)
}
# k8sクラスタ起動して、Kube Forwarder で MySQL service をローカルにポートフォワーディング
[host]$ skaffold dev

# Dockerコンテナを起動し、VS Code で開く
[host]$ devcontainer open .

# VS Code で開発・実行
[container]$ go run main.go
1

なお、今回はk8sクラスタとしたが、別のdocker-composeで起動したMySQLに接続するといったことも同様の手順で行える。

Docker Desktop WSL2 backend での注意点

Docker Desktop WSL2 backend では host.docker.internal はWSL2を指すのではなくWindows側となるようで、コンテナからWSL2上でlistenしているポートにアクセスするためには、WSL2のlocalhostForwarding機能を有効にする必要があるようだ。

環境

  • Mac:
    • OS: macOS Ventura 13.0.1
    • Docker Desktop: 4.12.0
    • Docker Engine: 20.10.17
  • Windows:
    • OS: Windows 11 Pro 22H2
    • Docker Desktop: 4.14.1
    • Docker Engine: 20.10.21
    • WSL: 1.0.1.0
    • WSL version: 2
    • WSL distribution: Ubuntu 20.04.4 LTS

参考

github.com/go-sql-driver/mysql で date型のカラムをtime.Time型で扱うと日付がズレるのを回避する

以下の記事で示したように、github.com/go-sql-driver/mysqlparseTimeloc オプションを適切に設定することで、Go の time.Time型とMySQLdatetime 型をタイムゾーンを考慮して適切に相互変換できる。

mrk21.hatenablog.com

しかし、同様にMySQLdate型をtime.Time型と相互変換しようとすると日付がズレる(ここではMySQL側のタイムゾーンJSTとする)。

const RFC3339Date = "2006-01-02"
...
db.Exec("create table test_date(dt date)")
...
date1 := time.Date(2022, 11, 2, 0, 0, 0, 0, time.UTC)
db.Exec("insert into test_date(dt) values (?)", date1)
rows, err := db.Query("select dt from test_date")
var date2 time.Time
for rows.Next() {
    rows.Scan(&date2)
    break
}
// date1: 2022-11-02(2022-11-02T00:00:00Z), date2: 2022-11-01(2022-11-01T15:00:00Z)
fmt.Printf("date1: %s(%s), date2: %s(%s)\n",
    date1.UTC().Format(RFC3339Date),
    date1.UTC().Format(time.RFC3339),
    date2.UTC().Format(RFC3339Date),
    date2.UTC().Format(time.RFC3339),
)

これは、Goでは日付をtime.Time型(時刻、タイムゾーンあり)で扱うので、Go => MySQL/MySQL => Goでのタイムゾーンの変換と、時刻/タイムゾーンの情報が欠落してしまうことに起因する。具体的には以下のようになる。

  1. 2022-11-02タイムゾーンUTCtime.Time型に格納: 2022-11-02T00:00:00Z
  2. MySQLタイムゾーンJSTに変換: 2022-11-02T09:00:00+09:00
  3. MySQLdate型に格納: 2022-11-02
  4. タイムゾーンJSTtime.Time型に格納: 2022-11-02T00:00:00+09:00
  5. タイムゾーンUTCに変換: 2022-11-01T15:00:00Z
  6. 日付を抽出: 2022-11-01

ここで 4. のようになるのは、github.com/go-sql-driver/mysql ではMySQLdate型からtime.Time型の値を生成するときは、以下のように時刻が00:00:00タイムゾーンlocオプションで示したものとなるからである。

func parseDateTime(b []byte, loc *time.Location) (time.Time, error) {
  ...
        if len(b) == 10 {
            return time.Date(year, month, day, 0, 0, 0, 0, loc), nil
        }

https://github.com/go-sql-driver/mysql/blob/fa1e4ed592daa59bcd70003263b5fc72e3de0137/utils.go#L139

この問題を防ぐには、date型を文字列でやり取りすればいいのだが、いちいちtime.Time型に変換するのは面倒である。そのため、タイムゾーンなしの日付型を新たにつくり、database/sqlで直接扱えるようにする。これには対象の型にdriver.Valuerおよびsql.Scannerインタフェースを満たすようにValue() (driver.Value, error)/Scan(value interface{}) errorメソッドを定義する。

type Date struct {
    year  int
    month time.Month
    day   int
}

func NewDate(year int, month time.Month, day int) *Date {
    t := time.Date(year, month, day, 0, 0, 0, 0, time.UTC)
    return &Date{
        year:  t.Year(),
        month: t.Month(),
        day:   t.Day(),
    }
}

func (d *Date) Year() int {
    return d.year
}

func (d *Date) Month() time.Month {
    return d.month
}

func (d *Date) Day() int {
    return d.day
}

func (d *Date) Format(layout string) string {
    return d.Time(nil).Format(layout)
}

func (d *Date) Time(loc *time.Location) time.Time {
    if loc == nil {
        loc = time.Local
    }
    return time.Date(d.year, d.month, d.day, 0, 0, 0, 0, loc)
}

// Go => MySQL
func (d Date) Value() (driver.Value, error) {
    return driver.Value(d.Format(RFC3339Date)), nil
}

// MySQL => Go
func (d *Date) Scan(value interface{}) error {
    var t mysql.NullTime
    err := t.Scan(value)
    if err != nil {
        return err
    }
    d.day = t.Time.Day()
    d.month = t.Time.Month()
    d.year = t.Time.Year()
    return nil
}

// interface check
var _ driver.Valuer = (*Date)(nil)
var _ sql.Scanner = (*Date)(nil)

そうすると、以下のようにdatabase/sqlで直接扱え、日付がズレることもなくなる。

date1 := NewDate(2022, 11, 2)

_, err = db.Exec("insert into test_date(dt) values (?)", date1)
if err != nil {
    log.Fatal(err)
}

rows, err := db.Query("select dt from test_date")
if err != nil {
    log.Fatal(err)
}

var date2 Date
for rows.Next() {
    rows.Scan(&date2)
    break
}
// date1: 2022-11-02, date2: 2022-11-02
fmt.Printf("date1: %s, date2: %s\n",
    date1.Format(RFC3339Date),
    date2.Format(RFC3339Date),
)

なおここで示したサンプルコードは以下にある。

github.com

環境

参考