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

環境

参考

github.com/go-sql-driver/mysql で datetime型のカラムのタイムゾーンを適切に扱う

MySQLdatetime型はタイムゾーンを保持しないため、MySQL側でJSTで取り扱うと決めたら、クライアント側で都度適切にタイムゾーンを変換する必要がある。

Go言語でこれを適切に行うためには、DB接続時に github.com/go-sql-driver/mysql のDSNで以下のようにする必要がある。なお、MySQL側ではJSTで保持するとする。

locale, _ := time.LoadLocation("Asia/Tokyo")
c := mysql.Config{
    DBName:    "dbname",
    User:      "root",
    Passwd:    "",
    Addr:      "db:3306",
    Net:       "tcp",
    Collation: "utf8mb4_bin",
    ParseTime: true,
    Loc:       locale,
}
dsn := c.FormatDSN()
db, _ := sql.Open("mysql", dsn)

まず、loc オプションで MySQL側のタイムゾーンを設定する(この場合はAsia/Tokyo)。そして、parseTime オプションを true にして Scan() 時に time.Time で受け取れるようにする。

このようにすることによって、MySQL側から得たdatetime型の値はタイムゾーンJSTtime.Time型に変換される。

db.Query("select cast('2022-11-01 10:00:00' as datetime)")
...
var t time.Time
rows.Scan(&t)
fmt.Println(t.Format(time.RFC3339)) // 2022-11-01T10:00:00+09:00

また、Go側からプレースホルダー経由でMySQLtime.Time型の値を渡したときは渡したtime.Time型のタイムゾーンがいずれであっても、MySQL側のタイムゾーンJSTに変換されようになる。

t, _ := time.Parse(time.RFC3339, "2022-11-01T10:00:00Z")
db.Query("select ?", t)
...
var tt string
rows.Scan(&tt)
fmt.Println(tt) // 2022-11-01 19:00:00

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

package main

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

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

func main() {
    locale, err := time.LoadLocation("Asia/Tokyo")
    if err != nil {
        panic(err)
    }
    c := mysql.Config{
        DBName:    "dbname",
        User:      "root",
        Passwd:    "",
        Addr:      "db:3306",
        Net:       "tcp",
        ParseTime: true,
        Loc:       locale,
    }
    dsn := c.FormatDSN()
    db, err := sql.Open("mysql", dsn)
    if err != nil {
        log.Fatal(err)
    }
    {
        rows, err := db.Query("select cast('2022-11-01 10:00:00' as datetime)")
        if err != nil {
            log.Fatal(err)
        }
        for rows.Next() {
            var t time.Time
            err := rows.Scan(&t)
            if err != nil {
                log.Fatal(err)
            }
            fmt.Println(t.Format(time.RFC3339)) // 2022-11-01T10:00:00+09:00
        }
    }
    {
        t, err := time.Parse(time.RFC3339, "2022-11-01T10:00:00Z")
        if err != nil {
            log.Fatal(err)
        }
        rows, err := db.Query("select ?", t)
        if err != nil {
            log.Fatal(err)
        }
        for rows.Next() {
            var tt string
            err := rows.Scan(&tt)
            if err != nil {
                log.Fatal(err)
            }
            fmt.Println(tt) // 2022-11-01 19:00:00
        }
    }
}

ちなみに、github.com/go-sql-driver/mysql側では以下のようになっており、DSNで渡したlocオプションで指定したタイムゾーンに変換されるようになっている。

Go => MySQL:

switch v := arg.(type) {
...
case time.Time:
    ...
        b, err = appendDateTime(b, v.In(mc.cfg.Loc))
    ...

https://github.com/go-sql-driver/mysql/blob/fa1e4ed592daa59bcd70003263b5fc72e3de0137/packets.go#L1119

MySQL => Go:

func parseDateTime(b []byte, loc *time.Location) (time.Time, error) {
    ...
            return time.Date(year, month, day, hour, min, sec, 0, loc), nil
    ...
}

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

なお、この記事で示したサンプルコードは sandbox/go-sql-driver-mysql-timezone at master · mrk21/sandbox にある。

環境

参考

Nuxt app で AWS SDK for JavaScript v3 を使えるようにする

AWS SDK for JavaScript v3 (@aws-sdk/***) では optional chaining operator ?. が使われており、Chrome や Node.js などでも割と最近になって使えるようになったので(2020年ごろ)、Nuxt app で使おうとすると設定にもよるがおそらく以下のようなエラーが出てビルドできない。

 ERROR  in ./node_modules/@aws-sdk/signature-v4/dist-es/getCanonicalHeaders.js                                                                                                             friendly-errors 22:57:25

Module parse failed: Unexpected token (10:30)                                                                                                                                              friendly-errors 22:57:25
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
|         const canonicalHeaderName = headerName.toLowerCase();
|         if (canonicalHeaderName in ALWAYS_UNSIGNABLE_HEADERS ||
>             unsignableHeaders?.has(canonicalHeaderName) ||
|             PROXY_HEADER_PATTERN.test(canonicalHeaderName) ||
|             SEC_HEADER_PATTERN.test(canonicalHeaderName)) {

なので、nuxt.config.js に以下の設定を加えて @aws-sdk をトランスパイルの対象にする必要がある。

export default {
  ...
  build: {
    transpile: [/@aws-sdk/],
  },
  ...
};

環境

  • Node.js: 16.13.2
  • nuxt: 2.15.7
  • @aws-sdk: 3.200.0

参考

xargs -P と export -f を使ってシェルスクリプトで並列処理を実現する

xargs-Pオプションを使うとパイプで渡された値を任意の並列数で並列処理することができます。以下では、普通に実行すると10秒かかる処理が、5並列で並列処理することによって2秒で実行することができます。

f.sh:

#!/bin/bash
echo $*;
sleep 1;
$ seq 10 | xargs -I % -P 5 ./f.sh %
1
2
3
4
5
6
7
8
9
10

そのため、シェルスクリプトで大量の項目をforを使って処理している部分をxargsに置き換えることによって高速化することができます。

しかし、xargsでは関数を使用できないので、このままでは並列処理したい部分を都度別のシェルスクリプトに分割しなくてはなりません。

$ function f { echo $*; sleep 1; }
$ seq 10 | xargs -I % -P 5 f %
xargs: f: No such file or directory
$ echo 'echo $*; sleep 1;' > f.sh
$ seq 10 | xargs -I % -P 5 sh ./f.sh %
1
2
3
4
5
6
7
8
9
10

ですが、bashにはexport -fという関数をエクスポートできる機能があり、これとbash -cを組み合わせることによって、いちいち並列処理をしたい部分をシェルスクリプトに書き出さなくてもよくなります。

$ function f { echo $*; sleep 1; }
$ export -f f
$ seq 10 | xargs -I % -P 5 bash -c 'f %'
1
2
3
4
5
6
7
8
9
10

しかし、xargs-Iオプションを使うとデリミタが改行になってしまうので、以下のような場合は意図した動作になりません。

$ function f { echo $*; sleep 1; }
$ export -f f
$ echo '1 2 3 4 5 6 7 8 9 10' | xargs -I % -P 5 bash -c 'f %'
1 2 3 4 5 6 7 8 9 10

この場合は、xargs-dオプションを使うことでデリミタを指定できるので、上記の場合はスペースを指定すれば意図した動作になります。

$ function f { echo $*; sleep 1; }
$ export -f f
$ echo '1 2 3 4 5 6 7 8 9 10' | xargs -d ' ' -I % -P 5 bash -c 'f %'
1
2
3
4
5
6
7
8
9
10

しかし、このオプションはGNU版のxargsにしかないので、Mac(BSD系)だと動きません。なので、brew install findutilsしてgxargsを入れて、alias xargs=gxargsとする必要があります。またaliasインタラクティブシェルでないと動作しないので、シェルスクリプトの場合は、さらにshopt -s expand_aliasesとする必要があります。

a.sh:

#!/bin/bash

# for Mac(BSD系)
shopt -s expand_aliases
alias xargs=gxargs

function f {
  echo $*;
  sleep 1;
}
export -f f
echo '1 2 3 4 5 6 7 8 9 10' | xargs -d ' ' -I % -P 5 bash -c 'f %'
$ ./a.sh
1
2
3
4
5
6
7
8
9
10

PowerShell で sudo 的なことをしたい

Windows Terminal 1.13 から管理者権限に昇格できるプロファイルを定義できるとはいえ、いちいち管理者権限が付与されたウィンドウを立ち上げたくはない。POSIXのように sudo <command> で同一ウィンドウ(タブ)で実行したい。

これには gsudo というそのまんまなことができるものがあるので導入する。winget を導入しているのであれば、以下でインストールできる。

winget install gerardog.gsudo

導入後は以下のように gsudo <command> で管理者権限で任意のコマンドを実行できる。

gsudo ls

しかし、$profile は読んでくれないようなので、$profile に定義されている関数などを実行したい場合は、以下のようにする必要がある。

gsudo "pwsh.exe -Login -Command { <command> }"

毎回これを入力するのは面倒くさいので、$profile に以下を定義しておく。

function Invoke-As-Admin() {
    if ($args.count -eq 0) {
        gsudo
        return
    }
    $cmd = $args -join ' '
    gsudo "pwsh.exe -Login -Command { $cmd }"
}

Set-Alias -Name: "sudo" -Value: "Invoke-As-Admin"

これでPOSIXライクに管理者権限で任意のコマンドを実行できるようになる。

sudo ls

なお、sudo (gsudo) とすると、POSIX でいう sudo su - のような挙動となる。

環境

  • OS: Windows 11 Pro
    • バージョン: 21H2
    • ビルド番号: 22000.526
  • PowerShell: 7.2.1

参考

WSL2でcodeコマンドやexplorer.exeがハングする

WSL2上でcodeコマンドを使ってVSCodeを開こうとしたらハングしてしまった。WSL2の再起動や高速スタートアップを無効にしてもWindowsの再起動をしても治らないし、codeコマンドだけではなくexplorer.exeもハングしてしまう。

調査していると、Windows上のパス(/mnt/c/)だと問題なく動作することがわかった。そこで、codeexplorer.exe も内部で利用しているであろう wslpath がおかしいのではないかと思い、wslpath -m を実行してみると以下のようになった。

$ wslpath -m /
//wsl.localhost/Ubuntu/
$ wslpath -m /mnt/c
C:/

WSL2上のパス(/など)だと//wsl.localhost/Ubuntu/のようになっていてなにかおかしい。WSL2上のパスは//wsl$/<ディストリビューション名>のはずである。さらに調査していると、どうやら、Build 21354//wsl$/<ディストリビューション名> から //wsl.localhost/<ディストリビューション名>/ に変更になり、今後はこちらが使われるらしい(現状は//wsl$/<ディストリビューション名>でも開ける)。

しかし、//wsl.localhost/Ubuntu でアクセスしてもハングしてしまいアクセスできない。このことから不具合の原因は wslpath でもなく //wsl.localhost/Ubuntu でアクセスできないことによるらしいことがわかった。また、ほかのディストリビューションでは問題なくアクセスできるのでWSL2自体に問題はなく、自分が使用している Ubuntu ディストリビューションが壊れているらしいことがわかった。

具体的にどこがおかしいか調査をしたかったが、いまいちわからなかったので Ubuntuクリーンインストールしたところ治った。

なお、環境は以下のとおりである。