Goで書いたWASMでfmt.Println()してからブラウザのコンソールに出力するまでを追う

先日さくらのクラウド向けCLI Usacloudをブラウザ上で動かせるChrome拡張UsaConをリリースしました。

febc-yamamoto.hatenablog.jp

このUsaConを作る際にいろいろ調べたことを備忘をかねて書いておきます。
(もし間違えている箇所などあったらご指摘いただけると嬉しいです。)

今回はfmt.Println()Hello WorldするだけのWASMを題材に、fmt.Println()からWebブラウザで出力されるまでのコードを追ってみます。

今回の環境

  • Go: 1.15.6
  • ブラウザ(動作確認用): Chrome 87

今回の題材アプリ

Hello Worldするだけの単純なアプリをWASMをしてビルドして実行します。

コードは↓↓だけの非常に単純なものです。

package main

import "fmt"

func main() {
    fmt.Println("hello wasm!")
}

あとはこれをビルドしてブラウザから実行可能にします。
実行にはGoに付属しているwasm_exec.htmlwasm_exec.jsを利用します。
この辺は以下のページに手順が書いてますので気になる方は参照してください。

WebAssembly · golang/go Wiki · GitHub

ビルドして実行可能にするまでの詳細はこちら(読み飛ばし可)

GOOSとGOARCHをセットしてビルド

$ GOOS=js GOARCH=wasm go build -o test.wasm main.go

Goに付属しているwasm_exec.htmlwasm_exec.jsをコピー

$ cp `go env GOPATH`/misc/wasm/wasm_exec.html ./
$ cp `go env GOPATH`/misc/wasm/wasm_exec.js ./

適当なWebサーバを起動

ここでは前述のWikiに従いgoexecを利用する形にしています。 既存のWebサーバを使う形やnpmでserveを使ったりする形でもOKです。

# install goexec
$ go get -u github.com/shurcooL/goexec
$ goexec 'http.ListenAndServe(`:8080`, http.FileServer(http.Dir(`.`)))'

ブラウザで開く

$ open http://localhost:8080/wasm_exec.html

画面上のRunボタンをクリックするとブラウザのコンソールにhello wasm!を表示されるはずです。

f:id:febc_yamamoto:20201225200858p:plain
RunボタンをクリックしてWASMを実行したところ

fmt.Println()の中を追う

早速fmt.Println()の中身を追ってみます。

func Println(a ...interface{}) (n int, err error) {
    return Fprintln(os.Stdout, a...)
}

func Fprintln(w io.Writer, a ...interface{}) (n int, err error) {
    p := newPrinter()
    p.doPrintln(a)
    n, err = w.Write(p.buf)
    p.free()
    return
}

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/fmt/print.go#L259-L275

os.Stdout(標準出力)のWrite()を呼んでますね。

ではWASMにした際の標準出力はどういう扱いになっているのでしょうか? まずこの辺をみていきます。

GOOS=js GOARCH=wasmの場合のos.Stdout

os.Stdoutの定義

os.Stdout*os.File型で、以下のように定義されています。

var (
    Stdin  = NewFile(uintptr(syscall.Stdin), "/dev/stdin")
    Stdout = NewFile(uintptr(syscall.Stdout), "/dev/stdout")
    Stderr = NewFile(uintptr(syscall.Stderr), "/dev/stderr")
)

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/os/file.go#L62-L66

syscall.Stdoutは以下のとおりです。

const (
    Stdin  = 0
    Stdout = 1
    Stderr = 2
)

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/syscall/syscall_js.go#L115-L119

別段変わったところはないですね。
NewFile()で何か変わったことをしているのでしょうか?
こちらも追ってみます。

os.NewFile()の実装の中身

// +build aix darwin dragonfly freebsd js,wasm linux netbsd openbsd solaris

func NewFile(fd uintptr, name string) *File {
    kind := kindNewFile
    if nb, err := unix.IsNonblock(int(fd)); err == nil && nb {
        kind = kindNonBlock
    }
    return newFile(fd, name, kind)
}

func newFile(fd uintptr, name string, kind newFileKind) *File {
    fdi := int(fd)

    // ...中略...

    f := &File{&file{
        pfd: poll.FD{
            Sysfd:         fdi,
            IsStream:      true,
            ZeroReadIsEOF: true,
        },
        name:        name,
        stdoutOrErr: fdi == 1 || fdi == 2,
    }}
    // ...中略...

    // poll.FDのInitを呼んできる
    if err := f.pfd.Init("file", pollable); err != nil {
        // ...中略...
    }
}

ソース: https://github.com/golang/go/blob/go1.15.6/src/os/file_unix.go

こちらはビルドタグ(ソース先頭のやつ)が+build aix darwin dragonfly freebsd js,wasm linux netbsd openbsd solarisとなっていますので、WASM用に特別なことをしてるわけじゃないですね。
もう少し中の方ではWASM向けに分岐しているのですが、今回は省略します。

次は*os.FileWrite()がどう実装されているか見ていきます。

*os.FileWrite()の実装

*os.FileWrite()

go/src/os/file.goで実装されています。

func (f *File) Write(b []byte) (n int, err error) {

    // ...中略...

    n, e := f.write(b)

    // ...中略...
}

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/os/file.go#L173

write()が呼ばれてるのでそちらを見てみます。

*os.Filewrite()

今度はgo/src/os/file_posix.go

func (f *File) write(b []byte) (n int, err error) {
    // poll.FDのWriteを呼んでいる
    n, err = f.pfd.Write(b)
    // ...中略...
}

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/os/file_posix.go#L48

f.pfd.Write()が呼ばれています。 *os.Filepfdフィールドはos.NewFile()の中でこっそり登場していましたね。
ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/os/file_unix.go#L114-L118

次にpoll.FDWrite()を見てみます。

poll.FDWrite()

// +build aix darwin dragonfly freebsd js,wasm linux netbsd openbsd solaris

// Write implements io.Writer.
func (fd *FD) Write(p []byte) (int, error) {
    // ...中略...

        // syscall.Writeを呼んでいる
        n, err := ignoringEINTR(func() (int, error) { return syscall.Write(fd.Sysfd, p[nn:max]) })

    // ...中略...

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/internal/poll/fd_unix.go#L267

syscall.Write()が呼ばれています。

GOOS=js GOARCH=wasmの場合のsyscall.Write()

go/src/syscall/fs_js.goで実装されています。

func Write(fd int, b []byte) (int, error) {
    // ...中略...

    // jsに値を渡す
    js.CopyBytesToJS(buf, b)

    // fsCall??
    n, err := fsCall("write", fd, buf, 0, len(b), nil)

    // ...中略...
}

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/syscall/fs_js.go#L400-L429

ここでようやくWASMっぽい部分が出てきました。 js.CopyBytesToJS()でバッファに値を入れてfsCall("write", ...)してます。

ちょっと寄り道: GoでビルドしたWASMとJavaScriptの値のやりとりはどうやってるの?

WASM/JavaScript両方からアクセスできるメモリ領域を介して値のやりとりをしています。
参考: WebAssembly.Memory

GoでビルドしたWASMの場合は、memという名前でこのMemoryがエクスポートされています。

f:id:febc_yamamoto:20201225211239p:plain
デバッガでWebAssembly.Instanceのexportsを確認しているところ

先ほど出てきたjs.CopyBytesToJS()はこの共有メモリ領域への書き込みを行なっています。 jsからも読み書きできるところにデータを書いておいてfsCall("write", ...)を呼んでいるということですね。

次にfsCall("write", ...)を追ってみます。

fsCall("write", ...)の実装

fsCallは以下のように実装されています。

func fsCall(name string, args ...interface{}) (js.Value, error) {
    // ...中略...

    // jsFSのCallを呼んでる
    jsFS.Call(name, append(args, f)...)

    // ...中略...
}

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/syscall/fs_js.go#L495-L523

jsFSはこちらです。

var jsFS = js.Global().Get("fs")

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/syscall/fs_js.go#L20

syscall.jsパッケージのGlobal()経由でGet("fs")してます。

syscal/js

syscall.jsGlobal()は以下のように定義されています。

func Global() Value {
    return valueGlobal
}

var (
    // valueGlobalは106行目で定義されている
    valueGlobal    = predefValue(5, typeFlagObject)

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/syscall/js/js.go#L144-L146

syscall/jsValue型ですね。ValueGet()は以下のように実装されています。

func (v Value) Get(p string) Value {
    // ...中略...

    r := makeValue(valueGet(v.ref, p))

    // ...中略...
}

func valueGet(v ref, p string) ref

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/syscall/js/js.go#L297-L304

最終的にvalueGet()を呼んでいます。
funcのボディがないですが、これはasmで実装されています。

TEXT ·valueGet(SB), NOSPLIT, $0
  CallImport
  RET

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/src/syscall/js/js_js.s#L15-L17

CallImportというやつがいますね。これは最終的にWebAssemblyにインポートしているfuncを呼ぶようになっているみたいです。 (この辺はまだちゃんと追えていません…)

インポートしてるfuncというのは、JavaScriptでWebAssemblyのインスタンスを作る時に使うWebAssembly.instantiateStreaming()の引数で渡すやつですね。
参考: MDN: WebAssembly.instantiateStreaming()

GoでビルドしたWASMの場合は、wasm_exec.jsの中で定義されているものを利用することになります。

    global.Go = class {
        constructor() {
            // ...中略...
            this.importObject = {
                go: {
                    // func valueGet(v ref, p string) ref
                    "syscall/js.valueGet": (sp) => {
                        const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
                        sp = this._inst.exports.getsp();
                        storeValue(sp + 32, result);
                    },

                    // func copyBytesToJS(dst ref, src []byte) (int, bool)
                    "syscall/js.copyBytesToJS": (sp) => {
                        const dst = loadValue(sp + 8);
                        const src = loadSlice(sp + 16);
                        if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
                            this.mem.setUint8(sp + 48, 0);
                            return;
                        }
                        const toCopy = src.subarray(0, dst.length);
                        dst.set(toCopy);
                        setInt64(sp + 40, toCopy.length);
                        this.mem.setUint8(sp + 48, 1);
                    },
                }
            };
        }
        // ...中略...

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/misc/wasm/wasm_exec.js#L248-L468

先ほど出てきたcopyBytesToJSもここで定義されていますね。 ということで最終的にjs上で定義されたfuncを呼ぶということがわかりました。

fsCall("write", ...)の実装(再び)

ということで改めてfsCall("write", ...)の実装を見てみます。

func fsCall(name string, args ...interface{}) (js.Value, error) {
    // ...中略...

    // jsFS.Call("write", ...)という引数になっている
    jsFS.Call(name, append(args, f)...)

    // ...中略...
}

var jsFS = js.Global().Get("fs")

js.Global().Get("fs")はjs上で定義されたvalueGet()を呼んでいました。 ここでjs上のglobal.jsを取得しています。取得したglobal.jsに対してCall("write", ...)してます。

これによりjs上のglobal.fswrite()を呼ぶようになっています。

ゴール: js上でconsole.log()を呼ぶ部分

global.fsには何が入ってる?

ブラウザ上で実行している場合は前述のwasm_exec.jsにより以下のように定義されています。

    if (!global.fs) {
        let outputBuf = "";
        global.fs = {
            constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused
            writeSync(fd, buf) {
                outputBuf += decoder.decode(buf);
                const nl = outputBuf.lastIndexOf("\n");
                if (nl != -1) {
                    console.log(outputBuf.substr(0, nl));
                    outputBuf = outputBuf.substr(nl + 1);
                }
                return buf.length;
            },
            write(fd, buf, offset, length, position, callback) {
                if (offset !== 0 || length !== buf.length || position !== null) {
                    callback(enosys());
                    return;
                }
                const n = this.writeSync(fd, buf);
                callback(null, n);
            },
            chmod(path, mode, callback) { callback(enosys()); },
            chown(path, uid, gid, callback) { callback(enosys()); },
            close(fd, callback) { callback(enosys()); },
            fchmod(fd, mode, callback) { callback(enosys()); },
            fchown(fd, uid, gid, callback) { callback(enosys()); },
            fstat(fd, callback) { callback(enosys()); },
            fsync(fd, callback) { callback(null); },
            ftruncate(fd, length, callback) { callback(enosys()); },
            lchown(path, uid, gid, callback) { callback(enosys()); },
            link(path, link, callback) { callback(enosys()); },
            lstat(path, callback) { callback(enosys()); },
            mkdir(path, perm, callback) { callback(enosys()); },
            open(path, flags, mode, callback) { callback(enosys()); },
            read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
            readdir(path, callback) { callback(enosys()); },
            readlink(path, callback) { callback(enosys()); },
            rename(from, to, callback) { callback(enosys()); },
            rmdir(path, callback) { callback(enosys()); },
            stat(path, callback) { callback(enosys()); },
            symlink(path, link, callback) { callback(enosys()); },
            truncate(path, length, callback) { callback(enosys()); },
            unlink(path, callback) { callback(enosys()); },
            utimes(path, atime, mtime, callback) { callback(enosys()); },
        };
    }

ソース: https://github.com/golang/go/blob/9b955d2d3fcff6a5bc8bce7bafdc4c634a28e95b/misc/wasm/wasm_exec.js#L36-L87

global.fsが未定義だったら最低限の実装で埋めています。 write()は以下の部分です。

            writeSync(fd, buf) {
                outputBuf += decoder.decode(buf);
                const nl = outputBuf.lastIndexOf("\n");
                if (nl != -1) {
                    console.log(outputBuf.substr(0, nl));
                    outputBuf = outputBuf.substr(nl + 1);
                }
                return buf.length;
            },
            write(fd, buf, offset, length, position, callback) {
                if (offset !== 0 || length !== buf.length || position !== null) {
                    callback(enosys());
                    return;
                }
                const n = this.writeSync(fd, buf);
                callback(null, n);
            },

write()writeSync()を呼び、最終的にconsole.log()されていますね。

長かったですがこれでGoのfmt.Println()からコンソール出力されるところまでたどり着きました。


まとめ

無理やりまとめると

  • fmt.Println()syscall.Write()を呼ぶ
  • syscall(のGOOS=js GOARCH=wasm向けの実装)やsyscall/jsはjs側と協調して動作するようになっている
  • js側の基本的な実装はwasm_exec.jsで行われている
  • wasm_exec.jsでのglobal.fsの実装は最低限しかない

みたいな感じでしょうか。

ひとまずGoから標準出力に出力することでブラウザのコンソールに出力できることはわかりました。
でも上記の通りglobal.fsは最低限の実装しかないです。CLIの場合は標準入力から読んだり、その他のファイルを開いたりしたいことがあるかと思います。
CLIを動かしたい場合は自分で実装しないといけない部分が結構ありそうです。

次回以降の記事でUsaConでこの辺の問題をどう解決していったかを取り上げたいと思います。

今回は以上です。

Usacloud v1.0 - WASM対応や全ゾーン一括操作など様々な改善を含むメジャーバージョンアップ

f:id:febc_yamamoto:20201221102556p:plain
UsaConのメイン画面

はじめに

さくらのクラウド向けCLIUsacloudのメジャーバージョンアップとなるv1.0.0をリリースしました。

github.com

Usacloud v1.0.0ではWebAssemblyに対応しました。このリリースに合わせブラウザ上で手軽にUsacloudを実行できるようにするChrome拡張UsaConもリリースしました。

chrome.google.com

この記事ではUsacloud v1.0とUsaConを紹介していきます。

Usacloud v1.0

さくらのクラウド向けCLIとして2017年3月に初版となるv0.0.1がリリースされました。  

初版のリリースが行われて以降、ご利用いただいた方々からのフィードバックを反映したりさくらのクラウド自体の進化に対応したりと地道にアップデートを重ねてきました。

f:id:febc_yamamoto:20201221103456p:plain
Usacloud 初版(v0.0.1)のリリース

これまでの累計ダウンロード数はGitHub ReleasesやDocker Hubを合計すると延べ10,000を超えました。

Usacloud v0系で不満だった点

私自身日常の作業にUsacloudを利用していたのですが、数年利用している中でいくつか不満を感じるようになってきていました。

例えば、

  • フラグ指定する際に指定する位置に気をつける必要がある
  • 複数のゾーンに跨がる処理の実装が困難
  • bash以外のシェル補完が行えない

というような点です。

例えば1点目は--zone=is1aのような指定をしたい場合、以下のように指定するしないといけないということです。

  • OKな例: usacloud --zone=tk1a server list
  • NGな例: usacloud server list --zone=tk1a

指定忘れに気付いたらヒストリーから呼び出して所定の位置にカーソルを移動して、、というのが地味にストレスでした。
(option+←などのキーボードショートカットやctrl+aで先頭に戻ってから右へ移動とかでもOKなんですがそれもめんどくさい)

これらの問題を解決するには依存ライブラリの切り替えやアップグレードが必要で、Usacloud側も大幅に修正する必要がありましたので メジャーバージョンアップして対応することにしました。

Usacloud v1.0系での改善や主な新機能

前述の細かな不満の解消や新機能の追加などを行なっています。

  • フラグ位置を気にしなくてよくなった
  • 全ゾーン一括操作対応
  • 操作対象リソースの指定にID or 名称 or タグを利用可能に
  • シェル補完でbash/zsh/fish/powershellをサポート(github.com/spf13/cobraのおかげ)
  • いわゆるDry Run機能(libsacloudのFakeドライバーというやつを利用)
  • ヘルプ表示の改善(候補値の表示やパラメータ例の追加など)
  • WebAssembly対応

他にも細かな改善がたくさん行われています。この中からいくつかピックアップして紹介します。

全ゾーン一括操作

--zoneフラグにallを指定することで全ゾーン一括操作が可能となりました。

# 全ゾーンのサーバを一覧表示
$ usacloud server list --zone=all

+------+--------------+---------+------+-----+--------+-----------+----------------+----------------+--------------+
| Zone |      ID      |  Name   | Tags | CPU | Memory | IPAddress | Upstream(Mbps) | InstanceStatus | InstanceHost |
+------+--------------+---------+------+-----+--------+-----------+----------------+----------------+--------------+
| tk1a | 100000000008 | example | -    | 1   | 1      | -         | -              | -              | -            |
| tk1b | 100000000011 | example | -    | 1   | 1      | -         | -              | -              | -            |
| is1a | 100000000012 | example | -    | 1   | 1      | -         | -              | -              | -            |
| is1b | 100000000010 | example | -    | 1   | 1      | -         | -              | -              | -            |
| tk1v | 100000000009 | example | -    | 1   | 1      | -         | -              | -              | -            |
+------+--------------+---------+------+-----+--------+-----------+----------------+----------------+--------------+

# 全ゾーンのサーバのうち、名称にexampleを含むサーバをシャットダウン
$ usacloud server shutdown --zone=all example

# 全ゾーンにサーバ作成
$ usacloud server create --name example ... --zone=all

参照系だけでなく、リソース作成や更新、電源操作なども一括で行えます。

操作対象リソースの指定にID or 名称 or タグを利用可能に

Usacloudはusacloud リソース コマンド 操作対象リソースのような書式となっています。
この中の操作対象リソースの部分はv0系だとID or Nameだったのですが、v1からはタグも利用可能になりました。

# 全ゾーンのサーバのうち、env=stagingというタグがついているサーバを一括シャットダウン
$ usacloud server shutdown "env=staging"

いわゆるDry Run機能

libsacloudにはFakeドライバーという機能があり、さくらのクラウドAPI呼び出しをローカルで擬似的に行えるようになっています。 主にテスト用途での利用を想定したものです。

# dry-run
$ usacloud server create --name example --fake

# 作成したリソースのデータを永続化しておきたい場合は--fake-storeを併用する
$ usacloud server create --name example --fake --fake-store ~/.usacloud/fake_store.json

Fakeドライバーはバックエンドストレージとしてインメモリ/ローカルファイルを選べるようになっており、ローカルファイルを用いればデータの永続化も行えるようになっています。
また、Terraformのさくらのクラウド向けプロバイダーPrometheusのさくらのクラウド向けExporterでもFakeドライバーが利用可能で、 例えばTerraformで作ったリソースをPrometheusで監視しつつUsacloudでリソースのCRUDや電源操作の動作確認、といったことを実際のリソースを作成することなく行えます。

RESTコマンドの追加

上級者向けではあるのですが個人的にお気に入りの機能なので紹介しておきます。

docs.usacloud.jp

さくらのクラウドAPIをUsacloudやAPIライブラリlibsacloudの抽象化を通さずに直接叩くためのコマンドです。

# 特定サーバの電源をOFFにする: https://developer.sakura.ad.jp/cloud/api/1.1/server/#delete_server_serverid_power
$ usacloud rest -X DELETE -d '{"Force":true}' https://secure.sakura.ad.jp/cloud/zone/is1a/api/cloud/1.1/server/$ID/power 

# APIルートURLは省略可能
$ usacloud rest -X DELETE -d '{"Force":true}' /server/$ID/power 

# パラメータはファイルからでもOK
$ usacloud rest -X DELETE -d parameter.json /server/$ID/power

curlを直接使えば良い、という感はありますが、APIキーやゾーンの指定は結構面倒なのでなかなか便利です。

WebAssembly(WASM)対応

GOOS=js GOARCH=wasm go buildでビルドできるようになりました。 実行するにはグルーコードが必要ですがnodejs上やブラウザ上でもUsacloudを動かせるようになりました。

# nodejs上でwasmを実行
$(go env GOROOT)/misc/wasm/go_js_wasm_exec usacloud.wasm -v

1.0.0 js/wasm, build c0ceb3ae

せっかくWebAssemblyに対応しましたのでブラウザ上でも動かせるように次で紹介するChrome拡張UsaConもUsacloud v1.0と同時リリースしました。

さくらのクラウドのコントロールパネル上でUsacloudを実行できるChrome拡張UsaCon

f:id:febc_yamamoto:20201222102242p:plain

chrome.google.com

利用方法についてはこちらのドキュメントを参照ください。 docs.usacloud.jp

UsaConは@wasmer/wasm-terminalというコンポーネントを用いて前述のUsacloud v1.0 WASM版を動かせるようにしたものです。 github.com

@wasmer/wasm-terminalXterm.jsComlinkなどを用いた、WASIという規格に対応したWASMをブラウザ上で実行できるコンポーネントなのですが、 これをGoで作成したWASMを実行できるように改修して利用しています。

(この辺の実装の詳細については機会があればどこかに書いておこうと思っています。)

今はストレージもエディタも付属していないのですが、今後BrowserFSみたいなやつでバックエンドをオブジェクトストレージにしてみたり、vim.wasmみたいにエディタを動かせるようにしたりすると面白いんじゃないかなと思っています。(出来るかはまだわかりませんが)

なお、UsaConはAPIキーの保存にCredential Management APIPasswordCredentialを利用していますのでChromeのみのサポートとなっています。 Firefox/Safariのサポートは今のところ出来ていません。Edgeはもしかしたら大丈夫かもしれませんが開発環境が手元にないので対応していません。

UsaConスクリーンショット

f:id:febc_yamamoto:20201221175149p:plain
UsaConでサーバ作成したところ

f:id:febc_yamamoto:20201221175225p:plain
UsaConでサーバの電源をONにしたところ

Usacloud v1.0 / UsaConの注意点

Usacloud v1.0では過去のバージョンと互換性のない変更がいくつか行われています。

例えば(APIで取得できる)全種類のリソースのサマリーを表示するusacloud summaryコマンドやデータベースアプライアンスのログを表示するusacloud database logsコマンドなどの廃止、パラメータ名の変更(--nw-mask-len => --netmask)などです。 Usacloud v0系からアップグレードされる方は前述の--fakeモードなどでDryRunして動作確認してみることをお勧めします。

その他にもいくつか破壊的な変更点があります。網羅的ではありませんがアップグレードガイドを用意していますのでこちらもご一読ください。

docs.usacloud.jp

まとめ

  • 様々な改善が行われたUsacloud v1.0をリリースしました
  • Usacloud v1.0をブラウザ上で動かすためのChrome拡張UsaConをリリースしました
  • Usacloud v1.0にはv0系と互換性のない変更も含まれるためアップグレードの場合は注意が必要

ということで是非ご利用ください。

以上です。

sakuracloud_exporter v0.13 − ショートメッセージサービス対応/GitHub Container Registryへの移行

さくらのクラウド向けPrometheus Exporterであるsakuracloud_exporterのv0.13をリリースしました。

github.com

主な変更点は以下2つです。

  • ショートメッセージサービスのサポート
  • GitHub Container Registryでのイメージ配布の開始

ショートメッセージサービスのサポート

ショートメッセージサービスとは

先月(2020年9月)に「さくらのクラウド ショートメッセージサービス(SMS)」が提供開始されました。

cloud-news.sakura.ad.jp

携帯電話番号をAPIまたはコントロールパネルから指定すると6桁の番号をスマートフォンや携帯電話に送信することができるサービスです。
2要素認証で使うよくあるアレですね。

こんな感じのメッセージが送られてきます。

f:id:febc_yamamoto:20201002162528j:plain

6桁の数値はさくらのクラウド側で生成、またはAPI( or コンパネ)から指定することができます。

sakuracloud_exporterでのショートメッセージサービスのサポート

sakuracloud_exporterではショートメッセージサービスのサポートとして以下2つのメトリクスを追加しました。

  • sakuracloud_esme_info : ESME自体の情報の参照用、常に1を返す
  • sakuracloud_esme_message_count: 処理したメッセージ送信リクエストの数

それぞれ以下のような値となります。

# HELP sakuracloud_esme_info A metric with a constant '1' value labeled by ESME information
# TYPE sakuracloud_esme_info gauge
sakuracloud_esme_info{description="description",id="123456789012",name="example",tags=",tag1,tag2,"} 1
# HELP sakuracloud_esme_message_count A count of messages handled by ESME
# TYPE sakuracloud_esme_message_count gauge
sakuracloud_esme_message_count{id="123456789012",name="example",status="All"} 4
sakuracloud_esme_message_count{id="123456789012",name="example",status="Accepted"} 2
sakuracloud_esme_message_count{id="123456789012",name="example",status="Delivered"} 2

sakuracloud_esme_message_countの方にはラベルとしてstatusという項目を設けています。
2要素認証SMSのAPIでは各メッセージごとにステータスを持っていますのでそれを集計してます。

// ショートメッセージサービスAPIレスポンスの例(ログ取得API)
{
  "ESME": {
    "logs": [
      {
        "messageId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
        "status": "Delivered", <-- この値ごとに集計している
        "otp": "123456",
        "destination": "819012345678",
        "sentAt": "...",
        "doneAt": "...",
        "retryCount": 0
      },
      ...
    ]
  }
}

メッセージ全体の件数はstatusがAllのやつを見ればOKです。

GitHub Container Registryでのイメージ配布の開始

v0.13.1からDocker Hubでのイメージ配布に加えてGitHub Container RegistryでもDockerイメージの配布を行うようになりました。

github.com

当面は両方で配布しますが、将来的にはGitHub Container Registryに1本化するつもりです。

使い方は従来のイメージ指定部分にghcr.ioを付け加えるだけです。

# DockerHubのイメージを利用する場合(従来)
$ docker run docker run <options> sacloud/sakuracloud_exporter

# GitHub Container Registryを利用する場合
$ docker run <options> ghcr.io/sacloud/sakuracloud_exporter 

既にドキュメント類もGitHub Container Registryを利用する方法に書き換えていますので、今からsakuracloud_exporterを利用し始める方はそちらをご利用ください。

余談: ESMEってなんの略?

今回追加されたショートメッセージサービスについてですが、さくらのクラウドAPI上ではesmeという名前になってます。

f:id:febc_yamamoto:20201002165823p:plain

なんの略だろうと調べてみたら、どうもExternal Short Message Entityの略っぽいですね。

k-tai.watch.impress.co.jp

なるほど。。勉強になりました。


ということで今回は以上です。是非ご利用ください。

Terraform+GitHub Actionsでコミュニティープロバイダーを利用する方法について

さくらのクラウド向けプロバイダーなどのTerraformのコミュニティプロバイダーをGitHub Actionsで利用する際に独自のGitHub Actionsを作成しているケースを見かけました。

ak1ra24.hatenablog.com

qiita.com

私が最近よく使っているhashicorp/setup-terraform+terraform.d配下にプロバイダーのバイナリーを置くという方法だとアクションを自作しなくても済むので今回紹介しておきます。

hashicorp/setup-terraform+terraform.d配下にプロバイダーのバイナリーを置く方法

こちらのリポジトリさくらのクラウドプロバイダーを利用する例を公開しました。

github.com

Terraformで利用するtfファイル郡+GitHub Actionsとしてhashicorp/setup-terraformを利用する構成になっています。 (注: この例ではstateの扱いを手抜きしてます。本番環境で利用する際はTerraform Cloudを使うなどの対応が必要です)

hashicorp/setup-terraform

HashiCorp社が公開しているGitHub Actionsです。 前述の記事ではhashicorp/terraform-github-actionsが参照されていましたが、こちらの後継がhashcorp/setup-terraformです。

www.terraform.io

こちらの記事に新旧比較がありました。

tech.medpeer.co.jp

hashicorp/setup-terraformを利用すれば基本的なTerraformでの操作は行えそうですね。

コミュニティプロバイダーの配置

コミュニティプロバイダーは~/.terraform.d/plugins/配下以外にもいくつか有効な置き場所があります。

www.terraform.io

特にワーキングディレクトリのterraform.d配下に置く方法は

  • tfファイル類と同じリポジトリ内に配置できる -> コードとプロバイダーのバージョン管理が容易
  • プロバイダーのダウンロードが行えないような環境(Air-Gap)でも利用できる

といった利点があります。Terraform Cloudなどでコミュニティプロバイダーを利用する場合もこの方法を利用しますね。

febc-yamamoto.hatenablog.jp

この2つを組み合わせることでGitHub Actionsでコミュニティープロバイダーを利用可能となります。

実行例: https://github.com/yamamoto-febc/terraform-github-action-test/runs/709137993?check_suite_focus=true

おまけ: Terraform v0.13以降では

febc-yamamoto.hatenablog.jp

こちらの記事のようにコミュニティプロバイダーがTerraform Registryに登録されていればプロバイダーのバイナリーをterraform.d配下に置く必要もなくなります。 この辺も楽になりそうですね。

終わりに

ということでTerraform+GitHub Actionsでコミュニティープロバイダーを利用する方法について紹介しました。
コミュニティプロバイダーでも割と簡易な手順で利用できますので是非お試しください。

以上です。

Terraform v0.13開発版でプロバイダーの自動インストール/更新を試す

今年の1月にTerraform Registryでプロバイダー(3rd含む)を配布できるようにするというアナウンスがありました。

www.hashicorp.com

ベータテストへの参加を募集していたので申し込んでおいたのですが、本日(2020/5/15)参加できるようになったとの連絡をいただいたので早速プロバイダーの公開&自動インストールを試しました。

Terraform Registryでのプロバイダーの配布

従来はterraform initを実行してもgithub.com/terraform-providers配下のプロバイダーのみが自動インストールされるだけで、コミュニティが開発しているプロバイダーは~/.terraform.d/plugins/配下に格納しておくなど手動でのインストール作業が必要でした。

これがTerraform Registryにあらかじめプロバイダーの登録を行っておくことで自動でインストール/更新が可能となりました。

試しにさくらのクラウド向けプロバイダーをTerraformRegistryで公開してみました。

f:id:febc_yamamoto:20200515100233p:plain

registry.terraform.io

(ベータテスト中は上記のプロバイダー自体が予告なく削除される可能性もあります)

プロバイダーのインストール/更新

公開したプロバイダーのインストール/更新を試してみます。

Terraform v0.13開発版のビルド

試すにはTerraform v0.13の開発版が必要です。今はビルド済みのバイナリが提供されていないため、GitHubからソースをクローンしてビルドする必要があります。 Goの開発環境を用意して以下でビルドします。

$ git clone https://github.com/hashicorp/terraform.git ; cd terraform
$ make dev

これで$GOPATH/bin/terraformが作成されます。

tfファイルの準備

以下のようなブロックをtfファイルに記述する必要があります。

terraform {
  required_providers {
    sakuracloud = {
      source = "sacloud/sakuracloud"
      version = "~> 2"
    }
  }
}

terraform initの実行

あとはterraform initを実行するだけです。

f:id:febc_yamamoto:20200515101128p:plain

インストールされましたね! インストールされたプラグインはカレントディレクトリ配下の.terraform/plugins配下のディレクトリ内に格納されます。

f:id:febc_yamamoto:20200515101353p:plain

これは便利ですね! 3rd プロバイダーは更新が面倒なために一度インストールしたらなかなか更新してもらえないという問題があったのですが、 これで更新も行えるようになるため非常に楽になると思います。 (もちろんバージョンをピンすることもできます)

ということで正式リリースがとても楽しみです!!

プロバイダー開発者向け: Terraform Registryでの配布までの作業

ここからはプロバイダー開発者向けにTerraform Registryでプロバイダーを配布するまでに必要な作業をまとめておきます。

全体的には以下のような作業が必要となります。

  • HashiCorp社にベータテストへの参加を申し込む(こちらの記事を参照)
  • 受付メールがきたら以下をHashiCorp社の担当者さんに伝える
    • ベータテストに参加する人のGitHubのユーザー名(Terraform Registryへのログインに使うアカウント)
    • 署名用GPG公開鍵
  • (HashiCorpさん側でTerraform Registryでのベータテストを有効にしてもらう)
  • プロバイダーのビルド/署名(GoReleaserのテンプレートが提供される)
  • (初回のみ) Terraform Registry上からプロバイダーの登録

Note: これ以外にもプロバイダーのリポジトリ内にドキュメントを指定のフォーマットで準備しておく必要があります。
プロバイダーのテンプレートなどを参考に準備しておきましょう。

これらはベータテスト参加時に提供されるドキュメント(多分一般公開されてるやつ)に書いてありますのでそちらに従って作業していけば大丈夫です。

Terraform Registry上での公開は以下のような感じです。

まず、ベータテストに参加すると右上のPublishメニュー内にProviderというメニューが生えます。

f:id:febc_yamamoto:20200515102956p:plain

これを選ぶと次にGitHub上のオーガニゼーションを選択する画面が出てきます。
プロバイダーのリポジトリがあるオーガニゼーションを選択しましょう。

f:id:febc_yamamoto:20200515103035p:plain

オーガニゼーションを選択すると、公開するプロバイダーのリポジトリを選択する画面になります。

f:id:febc_yamamoto:20200515103140p:plain

あとはPublishボタンを押すだけです。 規約に同意したらチェックを入れてPublishボタンを押せば公開されます。

f:id:febc_yamamoto:20200515103220p:plain

ドキュメントもterraform.ioのように綺麗に表示されてますね。

f:id:febc_yamamoto:20200515103347p:plain

なお、古くからプロバイダーを書いている場合、ドキュメントの手直しが少々必要かもしれません。 具体的には各リソースのドキュメント(.md)の先頭にサブカテゴリー(サイドバー)の定義を書いていく必要があります。 この辺はTerraformのドキュメントを参照の上適切に直しておきましょう。


ということでTerraform v0.13の機能を先取りで試してみました。

従来の3rdプロバイダーは更新が面倒なために、古いバージョンを使っていること起因の問題がよく発生していました。 これを解消できますのでかなり期待しています。

v0.13のリリースが待ち遠しいですね!

以上です。

TerraformerでGmailフィルタプロバイダーのコードをリバース生成する

はじめに: TerraformerへGmailフィルタプロバイダー対応がマージされた

既存の環境からTerraformのコード(tfファイル)+Stateファイル(terraform.tfstate)を生成してくれるTerraformerというツールがあります。

github.com

本日、このTerraformerにGmailフィルタプロバイダー対応を追加するPRがマージされました。

github.com

これにより、既にブラウザからある程度フィルタを作成済みのアカウントでもterraform-provider-gmailfilterを利用しやすくなりました。

背景

terraform-provider-gmailfilterについては以前以下の記事を書きました。

febc-yamamoto.hatenablog.jp

このプロバイダーはgmailfiltersというツールにインスパイアされて作成したものです。
ただ、gmailfiltersには既存のフィルタをエクスポートする機能がありますがこのプロバイダーでは実装していませんでした。

このため、既にGmail上でラベルやフィルタを作成済みのアカウントではGmailフィルタプロバイダーを導入するのが若干面倒でした。 (インポート機能は実装済みなため、tfファイルを書いて個別にインポートしていけば対応できてましたが面倒ですよね)

エクスポートについては、既に既存の環境からTerraformのコード(tfファイル)+Stateファイル(terraform.tfstate)を生成してくれるツールがいくつか存在するため、 プロバイダー側で提供するのではなくそれらを利用した方が良いと考えていたからです。

このために、それらのツールの中でも個人的に1番のお気に入りであるTerraformerGmailフィルタプロバイダー対応を行い、本日無事にマージされました。

使い方

Terraformerのビルド

今はまだマージされたばかりでこの変更を含めたリリースが行われていない状態ですので、利用するにはソースからビルドする必要があります。
README.mdを参考に手元でビルドします。

今回はDocker上でビルドしてみました。

# ソース一式をクローン
$ git clone https://github.com/GoogleCloudPlatform/terraformer.git; cd terraformer

# ビルド用コンテナ起動
$ docker run -it --rm -v $PWD:$PWD -w $PWD golang:1.14

# 依存モジュールのダウンロード
$ go mod download

# ビルド(ここではmacos向けにビルドしてます)
$ GOOS=darwin GOARCH="amd64" go build -v

これでカレントディレクトリにterraformer実行ファイルが生成されます。

Gmailフィルタプロバイダーのインストール

次にGmailフィルタプロバイダーをインストールしておきます。 こちらのリリースページから実行ファイルをダウンロードし、~/.terraform.d/plugins/darwin_amd64/配下(macosの場合)に配置しておきます。

Gmail APIの認証(Gmailフィルタプロバイダーを利用したことがない方)

次にGmail APIを有効にし、APIを利用するための認証設定を行なっておきます。 今回はApplication Default Credentialsを利用します。

まずは以下に従いAPIコンソールからGmail APIを有効化します。

support.google.com

次にクレデンシャルの作成、OAuthクライアントの作成、シークレットファイルのダウンロードを行います。 ダウンロードしたファイルはclient_secret.jsonという名前で保存しておきます。

保存したら以下のコマンドを実行します。

gcloud auth application-default login \
  --client-id-file=client_secret.json \
  --scopes \
https://www.googleapis.com/auth/gmail.labels,\
https://www.googleapis.com/auth/gmail.settings.basic

実行するとブラウザでアクセスを許可しても良いか尋ねる画面が開きますので画面に従い許可していきます。

これで準備完了です。

Terraformerの実行

あとはTerraformerを実行するだけです。
以下のコマンドで既存のラベルとフィルタからtfファイル+terraform.tfstateファイルを生成できます。

$ ./terraformer import gmailfilter -r label,filter

デフォルトだとカレントディレクトリ配下のgenerated/gmailfilter/{filter,label}ディレクトリに以下のようなファイルが生成されます。

例: generated/gmailfilter/filter/filter.tf

resource "gmailfilter_filter" "tfer--ANe1BmgZqKtSU0e8o24MkdHlWUd6JsoQ9PRIug" {
  action {
    add_label_ids    = ["${data.terraform_remote_state.label.outputs.gmailfilter_label_tfer--Google_id}"]
    remove_label_ids = ["INBOX"]
  }

  criteria {
    exclude_chats  = "false"
    has_attachment = "false"
    query          = "from:mail-noreply@google.com"
    size           = "0"
  }
}

resource "gmailfilter_filter" "tfer--ANe1BmiABTbNsxNxhKfdIlJN-002D-2BJRicNJdqRYQ" {
  action {
    add_label_ids    = ["${data.terraform_remote_state.label.outputs.gmailfilter_label_tfer--parent_child_id}"]
    remove_label_ids = ["INBOX"]
  }

  criteria {
    exclude_chats  = "false"
    from           = "foobar@example.com"
    has_attachment = "false"
    size           = "0"
  }
}

# ...

例: generated/gmailfilter/label/label.tf

resource "gmailfilter_label" "tfer--Google" {
  label_list_visibility   = "labelShow"
  message_list_visibility = "show"
  name                    = "Google"
}

resource "gmailfilter_label" "tfer--parent" {
  label_list_visibility   = "labelShow"
  message_list_visibility = "show"
  name                    = "parent"
}

resource "gmailfilter_label" "tfer--parent_child" {
  label_list_visibility   = "labelShow"
  message_list_visibility = "show"
  name                    = "parent/child"
}

リソース名が若干長いのが気になりますが、1からtfファイルを書くよりこれを編集していく方が楽だと思います。

注意点

生成されるコードではサブラベルでの親ラベルへの依存の設定がうまくいかないです。 サブラベルを利用している場合は適宜tfファイルを書き換える(label.tfのnameなど)必要がありますのでご注意ください。

# 変更前
resource "gmailfilter_label" "tfer--parent_child" {
  label_list_visibility   = "labelShow"
  message_list_visibility = "show"
  name                    = "parent/child"
}

# 変更後
resource "gmailfilter_label" "tfer--parent_child" {
  label_list_visibility   = "labelShow"
  message_list_visibility = "show"
  name                    = "${gmailfilter_label.tfer--parent.name}/child"
}

終わりに

ということでTerraformerを併用することでGmailプロバイダーのコードを書くのが少し楽になりそうです。
是非お試しください。

以上です。

Gmailのフィルタを管理するためのTerraformプロバイダーを作った

少し前にこちらの記事でgmailfiltersというツールを知りました。

kakakakakku.hatenablog.com

github.com

gmailfiltersはとても良さそうなのですが、なんでもTerraformおじさん的にはTOMLよりHCLで設定を書きたいなと思ったので この土日でTerraformプロバイダーを実装してみました。

github.com

Gmailのフィルタ/ラベルを設定するTerraformプロバイダー terraform-provider-gmailfilter

Gmail APIでフィルタやラベルの登録/更新/削除が行えます。

例えばfoobar@example.comから来たメールをexampleラベルに振り分けるには以下のようなHCLコードとなります。

# ラベル"INBOX"を参照するためのデータソース
data gmailfilter_label "INBOX" {
  name = "INBOX"
}

# ラベル"example"を作成
resource gmailfilter_label "example" {
  name = "example"
}

# フィルタの登録
resource gmailfilter_filter "example" {
  criteria {
    from = "foobar@example.com"
  }
  action {
    add_label_ids    = [gmailfilter_label.example.id]
    remove_label_ids = [data.gmailfilter_label.INBOX.id]
  }
}

フィルタには以下のような項目が指定可能です。

resource gmailfilter_filter "example" {
  criteria {
    from            = "foobar@example.com"
    exclude_chats   = false
    has_attachment  = false
    negated_query   = "from:someuser@example.com rfc822msgid: is:unread"
    query           = "from:someuser@example.com rfc822msgid: is:unread"
    size            = 1000
    size_comparison = "larger"
    subject         = "example"
    to              = "example@"
  }
  action {
    add_label_ids    = [gmailfilter_label.example.id]
    remove_label_ids = ["INBOX"]
    forward          = "destination@example.com"
  }
}

利用例: 入れ子になったラベル(サブラベル)の管理

サブラベルも作成できます。

resource gmailfilter_label "top" {
  name = "top-level"
}

resource gmailfilter_label "child1" {
  name = "${gmailfilter_label.top.name}/child1"
}

resource gmailfilter_label "child2" {
  name = "${gmailfilter_label.child1.name}/child2"
}

これをterraform applyすると以下のようになります。

f:id:febc_yamamoto:20200426173455p:plain

利用例: 添付メールに Attachment ラベルを付ける

元記事にで紹介されてた例です。 こちらは以下のようなコードになります。

# Attachmentラベルの作成
resource gmailfilter_label "attachment" {
  name = "Attachment"
}

# フィルターの作成
resource gmailfilter_filter "attachment" {
  criteria {
    has_attachment  = true
  }
  action {
    add_label_ids    = [gmailfilter_label.attachment.id]
  }
}

利用例: Amazon 購買メールに Amazon ラベルを付ける

こちらも元記事で紹介されていた例です。
元記事ではqueryOrを用いてますが、このプロバイダーではqueryOrに相当するものは提供していません。
代わりにHCL上で利用できる、Terraformに組み込みのFunctionsを利用してクエリを組み立てていきます。

# 対象のメールアドレスのリスト
locals {
  addresses = [
    "from:shipment-tracking@amazon.co.jp",
    "from:auto-confirm@amazon.co.jp",
    "from:order-update@amazon.co.jp",
  ]
}

# Amazonラベルの作成
resource gmailfilter_label "amazon" {
  name = "Amazon"
}

# フィルターの作成
resource gmailfilter_filter "amazon" {
  criteria {
    query = join(" OR ", local.addresses) # joinでqueryを組み立てる
  }
  action {
    add_label_ids    = [gmailfilter_label.amazon.id]
    remove_label_ids = ["INBOX"]
  }
}

おまけ: 実装について

gmailfiltersではqueryToなどの便利機能を提供していますが、このプロバイダーではその辺を提供せず、GmailAPIをそのままTerraformでラップしたような形としました。
入力/編集できる項目や値はAPIドキュメントを見ればわかるようになっています。

Terraformを使う層であれば自分でAPIドキュメントを読むくらいするだろうという想定です。
なので出来ることの詳細はGmailAPIをみていただくのが一番早いと思います。

注意事項

このプロバイダーでは転送先アドレスの管理は実装していません。 もしフィルタで転送を行いたい場合はあらかじめWebブラウザなどから転送先アドレスを登録しておく必要があります。 参考: Gmail のメールを他のアカウントに自動転送する

また、フィルタを作成しても既存のメールには適用されないようでした。
APIで出来るのかもしれないのですがまだちゃんと調べてないです。

もし適用方法をご存知の方がいらっしゃいましたら教えてもらえると嬉しいです。 (PRもらえるともっと嬉しいです)

終わりに

G Suiteにも対応しておりなかなか便利だと思います。
Terraform好きな方はぜひお試しくださいー。

以上です。