Runner in the High

技術のことをかくこころみ

OpenTelemetryでもGin+GAEのログをリクエスト単位でグルーピングする

前回のOpenCensusでは比較的すんなりとGAEのログをリクエスト単位でグルーピングさせることができたが、OpenTelemetryの場合には追加でGoogleCloudPlatromが用意しているpropagatorを使わねばならない。 github.com

以下がとりあえず動く超最小構成のミドルウェア

package middleware

import (
    cloudpropagator "github.com/GoogleCloudPlatform/opentelemetry-operations-go/propagator"
    "github.com/gin-gonic/gin"
    "go.opentelemetry.io/otel/propagation"
    "go.opentelemetry.io/otel/trace"
)

func Tracer() gin.HandlerFunc {
    return func(c *gin.Context) {
        ctx := c.Request.Context()

        // まずはX-Cloud-Trace-Contextからの読み取りをトライ
        // ここでエラーを拾っても何もできないので意図的にエラーは無視する
        if sc, _ := cloudpropagator.New().SpanContextFromRequest(c.Request); sc.IsValid() {
            ctx = trace.ContextWithRemoteSpanContext(ctx, sc)
        } else {
             // X-Cloud-Trace-ContextからValidな値が取れない場合には
             // traceparentヘッダからのTraceID/SpanIDのパースを試してみる
             prop := propagation.TraceContext{}
             ctx = prop.Extract(ctx, propagation.HeaderCarrier(c.Request.Header))
        }

        // 必要に応じてNewTracerProviderの中でsamplerやexporterを指定する
        traceProvider := sdktrace.NewTracerProvider()
        defer traceProvider.Shutdown(c)

        tracer := traceProvider.Tracer("") // Empty: use default tracer name
        spanCtx, span := tracer.Start(ctx, "app")
        defer span.End()

        c.Request = c.Request.WithContext(spanCtx)
        c.Next()
    }
}

あとは前回の記事を参考にしてContextからSpanIDとTraceIDを取り出してロガーで送ってやればいい。まじめにやるならサンプラーやエクスポータなどもちゃんと設定してやったほうがいいが、そのあたりはこの記事では触れない。

OpenCensusのときはパッケージの中にStackdriver用のエクスポータが用意されていて、その中で X-Cloud-Trace-Context ヘッダから値を取り出すパーサが実装されていた。しかしOpenTelemetryからはどうやらエクスポータはインターフェイスのみを提供し、プラットフォーム固有のエクスポータは自分たちで用意してくれという方針になったように見える。Googleが使っている X-Cloud-Trace-Context 相当のヘッダはW3Ctraceparent としてベンダ非依存のオープンな仕様へと標準化されたらしく、むしろGoogle側がOpenTelemetryの仕様に従うという構図になっているっぽい。

もしかするとOpenCensus時代はまだ分散トレーシングが黎明期(?)だったこともあって、むしろ先立って分散トレーシングを製品化したり大規模に使ったりしていたGoogle側の仕様をOpenCensus側が取り入れたのかもしれない。

ヘッダの仕様に関しては以下のドキュメントが参考になった。 cloud.google.com

GinのOpenCensusミドルウェアを作ってCloud Loggngのログがリクエスト単位にグルーピングされるようにする

OpenCensusはdeprecatedされているので本当はOpenTelemetryを使った方がいいのだけれど、やったのでメモがてらに残しておく。アプリケーションの実行環境はGAEでやった。

追記: OpenTelemetryでやる方法も書きました izumisy.work

まずはログのグルーピング(Span)をリクエスト毎に開始するためのミドルウェアを作る。

package middleware

import (
    "github.com/gin-gonic/gin"
    "go.opencensus.io/exporter/stackdriver/propagation"
    "go.opencensus.io/trace"
)

//
// ルーティングの実装ではUseを使って以下のミドルウェアを登録しておく
// router.Use(Tracer())
//

func Tracer() gin.HandlerFunc {
    return func(c *gin.Context) {
        ctxWithSpan, span := trace.StartSpan(c, "app")

        httpFormat := propagation.HTTPFormat{}
        if sc, ok := httpFormat.SpanContextFromRequest(c.Request); ok {
            ctxWithSpan, span = trace.StartSpanWithRemoteParent(c, "app", sc)
        }

        c.Request = c.Request.WithContext(ctxWithSpan)
        c.Next()
        span.End()
    }
}

TracerミドルウェアによってContextにグルーピングのために必要な情報がセットされるので、それを取得する関数も用意しておく。

gin.Context の場合にはRequestからContextを取り出すようにしているのがポイント。

package log

import (
    "context"

    "github.com/gin-gonic/gin"
    "go.opencensus.io/trace"
)

type Tracer struct {
    SpanID  string
    TraceID string
}

func ExtractTracer(ctx context.Context) Tracer {
    sc := trace.SpanContext{}

    if ginCtx, ok := ctx.(*gin.Context); ok {
        sc = trace.FromContext(ginCtx.Request.Context()).SpanContext()
    } else {
        sc = trace.FromContext(ctx).SpanContext()
    }

    return Tracer{
        SpanID:  sc.SpanID.String(),
        TraceID: sc.TraceID.String(),
    }
}

あとはCloud Loggingにデータを送信するタイミングで、上で作ったExtractTracerを用いてContextからSpanIDとTraceIDを取り出し、それぞれ適切な形でセットしてやればok

package log

import (
    "fmt"
    "os"
    "log"

    "cloud.google.com/go/logging"
)

func Log(ctx context.Context, severity logging.Severity, timestamp time.Time, text string) {
    projectID := os.Getenv("GOOGLE_CLOUD_PROJECT")
    client, err := logging.NewClient(ctx, projectID)
    if err != nil {
        log.Fatalf("Failed to create client: %v", err)
    }

    tracer := ExtractTracer(ctx)
    logger = client.Logger("ServeHTTP")
    logger.Log(logging.Entry{
        Timestamp: timestamp,
        Severity:  severity,
        Payload:   text,
        SpanID:    tracer.SpanID,
        Trace:     fmt.Sprintf("projects/%s/traces/%s", projectID, tracer.TraceID),
    })
}

DeNAが出しているaelogというパッケージを使えばこの辺全部よしなにやってくれるっぽい。

Ginでなにもせず使えるかは試してない。ミドルウェアの型定義的に gin.WrapH とかでラップしてUseしてあげれば使えるような気もするけど、どうなんだろう github.com

追記: 調べたらこんなのもあった。 github.com Cloud Loggingとのつなぎこみだけ自分で作ってこういうのを使うでもいいかも。

2021年の振り返り

今年もあっという間に終わってしまった...

仕事について

まだまだマネージャとしてはペーペーだけれども、1年間マネージャという役職で仕事をやりきったのがこの2021年だった。読んだ本をまとめた記事も書いた。 izumisy.work

具体の仕事のはなしにはなるが、1-5月にかけてかなりダイナミックにフロントエンドのUIや体験周りを改修するプロジェクトのリーダーをしていた。これがまた影響範囲も関わるステイクホルダーの人数も大きく、正直途中から着地点を失ったPJになりかけてしまった。結果的にはどうにか着地点を見出すことができたものの、その過程では他開発チームにもチームに所属するメンバーにも様々な形で迷惑をかけてしまい、自分としては大きな反省をする出来事になった。このときの自分の中には「マネージャとしての自分がどういう動きをするべきか」のビジョンが全くと言っていいほどなかった。

この出来事以降、自分の中でマネージャというのはどのような形でチームに(そしてチームのメンバーに)貢献するべきかを考え直して、最終的に自分の中では「チームの生産性の倍率を上げるのがマネージャである」と考えるようになった。マネージャが自ら手を動かしてメンバーと同じ仕事を現場でやっているばかりでは絶対に生産性の倍率は上がらない。過去を振り返り、現状を把握し、それを未来のロードマップに繋げる。その過程で少しづつチームの、ひいては組織の倍率を上げていかねばならない。そのために必要なことはなんでもやる、それがマネージャという役職なんだろうな、というのが現状の自分の解釈として落ち着いた。

エンジニアとして

Elmのパッケージをいくつか作ったりバージョンアップしたりした izumisy.work izumisy.work

本業とは別件で、音声や信号処理に関することをいろいろ調べたりしていた。今後もSide Hustle的な感じでこれ系の技術の勉強に投資していく予定。おそらくこれは金になる... izumisy.work izumisy.work

あとはEarthlyにPRを出したりもしていた。これしかないけど。 github.com

生活

ノリと勢いで千葉に家を建てた。

f:id:IzumiSy:20211226154427j:plain:w500
当たり障りない玄関の画像

リモートワークばっかりなので仕事部屋は当然用意して、他にもランドリールームや脱衣室をつけてみたりと家のことを決めるのは大変なことも多かったが概ね楽しかった。ただ、どっかのブログで見たようなネットワークにこだわったり断熱材にめちゃくちゃこだわったりしたわけではないので、特に書けることはない。

ひとつ確実に言えることは、船橋とか市川らへんで駅に近いところには4LDKの家なんて到底買えないということぐらい... 東京なんてもってのほか。東京にある程度の家建ててる人って一体どういう仕事をしてるんでしょうね?

ネコ

なんかでかくなってきた。おなかがぽよぽよすぎる。

f:id:IzumiSy:20211226155427j:plain:w500
かわいい

来年に向けて

2年近くマネージャというポジションで仕事をさせてもらえたことで、なんとなくマネージャとはなんぞやが見えてきたような気がする。そういう意味では、2021年は自分なりのゲームの進め方が分かってきたような、風の読み方が分かってきたようなそんな感じだった。とはいいつつも、組織的な成長に自分が自信をもって貢献できたと言い切れない感覚はまだある。ゲームの進め方が分かっても、まだ勝ち筋が見えなかったりその場その場で必要な打ち手を自分の中で見つけられなかったりするもどかしさみたいな、そんな感覚。

2022年はこの手探り感にもう少し自信が上乗せされるようなそんな仕事がしたい。とりあえず、なにか良さげな本を読んだり偉い人の講演を聞いたりするのではなく、とにかく自分が責任を取りつつゴールに向かってめちゃくちゃ考え抜いた先に結果がでて初めてあとから自信がおまけで付いてくるんだろな~と思っているので、来年は責任とる仕事をやりまくります。

あとチームトポロジー本で得た学びを組織設計とか開発プロセスの改善に活かしていきたい。

ITベンチャーのミドルマネージャ初心者として参考になった3冊

izumisy.work

マネージャという役職として働くようになってそろそろ2年目が見えてきた。

自分の所属するUniposでは、複数チームが開発組織に並存するようになってから日が浅く(とは言っても1年以上は経っているが)それにともなって各チームをマネジメントするミドルマネージャ(中間管理職)というポジションはまだ黎明期的な立ち位置にある。転職組はある程度経験者もいるとはいえ基本的にはみんながニューゲーム状態であり、自分も全くと言っていいほど手探りな進み方をしている。

そんな中でも、ある程度「ミドルマネージャかくあるべし」を知るのに役立った書籍を3つ紹介する。

HIGH OUTPUT MANAGEMENT

マネージャという立ち位置ならこれを読まない人はいないレベルのやつ。

なにから読むべきか... というときにはまずこれで間違いない。とはいえ、文章もなかなか小難しい感じでもありかつボリューミイなのである程度腰を据えて読む必要はある。

この本を読むまで自分の中でマネージャとしての仕事の使い方は正直微妙で、マネージャであるのに自分もプロジェクトの中で開発メンバーとしてがっつりコードを書いていた。もちろんマネージャがコードを書くのは悪いことではないが、そのときの自分の仕事の比率といえばマネージャ業務と実装業務でほぼ2:8くらいの割合だった。思い返せば、その頃の自分はひとりのエンジニアとして出してきたバリューをマネージャになって失うのが怖いという理由で、マネジメント業務を満足にやらずにコーディングに逃げていた*1ような気がする。

マネージャというポジションにいるにも関わらず、やっていることはマネージャではないメンバと同じというバリューゼロの最悪なパターンを体現してしまった反省として「マネージャというポジションの出すべき価値はなにか」を探し回った結果この本にたどり着いた。有名な本なので言うまでもないが、自分もこの本を読んでマネージャというポジションへの捉え方が大きく変わり、自分の時間の使い方に自信が持てた。

ちなみにこれを読むのがかったるいという人は、田端信太郎が田端大学で出しているYoutubeのこの動画を見るだけでもとっかかりは知れるのでおすすめ。これを見てもっと知りたくなったら本を読むといいかもしれない。

www.youtube.com

EMPOWERED

会社で行われているマネージャ陣の輪読会で読んだ本。

マネージャとしてのリーダーシップにはプロジェクトマネジメントや育成はもちろん包含されるが、それと同時にマネージャ自身が「最もプロダクトをマネジメントできている存在」であることが求められる。EMPOWEREDはそのような内容にフォーカスを当てたプロダクトマネジメントの本になっている。

組織によってはあえて"プロジェクト"マネージャという呼称をしないところもある。これはマネージャの責務というのは人やプロジェクトのマネジメントだけではなくプロダクトの行く方向もマネジメントするべきだ、ということを示している。クライアントワークや受託開発ではなく自社開発のSaaSプロダクトなどを抱えた企業においては、プロダクトマネジメントという考え方は要求されて当然*2なものだ。

この本の中でプロダクトマネジメントに包含される形で現れる「プロダクトディスカバリ」の考え方はマネージャの時間の使い方の観点で重要な要素になりえる。マネージャはチームにとってあらゆる手段を講じて仕事を作る責任を与えられているはずだし、その仕事はデリバリや開発プロセスの改善的だけではなくプロダクトそのものの在り方を変えるものにもなり得る。組織や個人の嗜好にもよるとは思うが、マネージャとしての仕事の幅を広げることはプロダクトにとっても自分のキャリアにとっても(ゆくゆくはチームにとっても)重要だと思う。

最後に、個人的に印象に残っているのは書籍中のコーチングセクションにある「ナラティブ」と呼ばれるトピックの章。2-3ページ程度しかない中身はないが、自分にとってのリーダーシップとはこのナラティブに集約されるのではないかとすら感じる。マネジメントが人間同士のコミュニケーションである限り、どのような場においてもナラティブせずに仕事は前に進められない。この辺のスキルを根本から考えるには、おそらくアリストテレスの弁論術や、デール・カーネギーの「人を動かす」などにつながってくるような気がする。あとは同じくデール・カーネギーの「話し方入門」もいいかもしれない。

アート・オブ・プロジェクトマネジメント

プロジェクトマネジメントはマネージャにとって呼吸をするレベルで出来て当然のスキルだと思うが、それにも関わらずプロジェクトのマネジメントの前提知識はどこにも転がっていない。それでもなんとなくある程度やってみると、プロジェクトマネジメントというのは「スコープ」「人」「スケジュール」の三竦みというところまでは分かってくる。この辺は大抵の場合、ある程度は体得的なものだと思う。

そういった体得的なものをある程度体系づけて事前に知っておくのにこの本はぴったりだと思う。プロジェクトマネジメントに必要な「マインド」「準備」「戦略」「戦術」がたくさん詰まっている。特に「中盤の戦略」「終盤の戦略」の章は具体性の高い話が多いので、ある程度のプロジェクト(~1,2か月規模)をマネジメントする際にはやっておけば間違いない事柄を一気に体系立てて学べる。

とはいえ、この本の前半部分を読めば察してしまうのがやはりマネジメントとは気合そのものだという事実だと思う。マネージ(manage)の日本語訳が「どうにかする、うまくする」になっていることからも分かるように、結局は「どうにかする気合」がマネジメントの本筋(特にプロジェクトが大きければ大きいほど...)なんだろうなという感想になってしまうのは、マネージャとしてツラいところでもあり、ワクワクするところでもある。

*1:なお、このプロジェクトはとんでもない結末を迎えてしまった。

*2:ここまで要求されるミドルマネージャってのは大変だとは思うけど、大変なことが普通にできたほうがライバルは減らせるからお得だよね

Earthlyがもつ2種類のキャッシュ機構(inline, explicit)について

izumisy.work

前回はあまりキャッシュについて触れることができなかったので、今回はEarthlyが持つキャッシュ機構だけにフォーカスして書く。

Earthlyにおけるキャッシュ

Earthlyは環境に依存しない実行環境を提供するため、キャッシュにおいてもローカルファイルキャッシュのようなホストマシンに依存するタイプのキャッシュ機構*1を提供しない。代わりに、ビルドプロセスの中で SAVE IMAGE を呼んでいるターゲットの成果物をDockerイメージ化してキャッシュしコンテナレジストリに格納する。ビルドの実行時にはEarthlyが必要なDockerイメージのキャッシュをコンテナレジストリから取得する。

キャッシュイメージをpushするコンテナレジストリとして、使える人はDockerhubを使うのがいいとは思うものの、Dockerhubは無料プランだとレートリミットがかなり厳しくキャッシュとしてreadが頻繁に行われるとすぐ上限に達してしまう。なので、個人利用でEarthlyを使うのならばGithub Container Registryをキャッシュ用のコンテナレジストリとして使うのがおすすめ。

www.kaizenprogrammer.com

ここからは、Earthlyがキャッシュの機能として2種類提供しているInline cacheExplicit cacheについて説明する。

Inline cache

ターゲットの成果物であるDockerイメージを個別にキャッシュする。

以下を例としてIzumiSy/go-cleanarchitectureのEarthfile から抜粋。

deps:
  RUN apk add --no-cache build-base
  COPY go.mod go.sum .
  RUN go mod download
  SAVE IMAGE --cache-hint

build:
  FROM +deps
  COPY . .
  RUN go build -o build/go-cleanarchitecture main.go
  SAVE ARTIFACT build/go-cleanarchitecture AS LOCAL build/go-cleanarchitecture

image:
  COPY +build/go-cleanarchitecture .
  EXPOSE 8080
  ENTRYPOINT ["/go-cleanarchitecture/go-cleanarchitecture"]
  SAVE IMAGE --push ghcr.io/izumisy/go-cleanarchitecture:cache

上記のEarthfileのうち一番最後の行の SAVE IMAGE --push ghcr.io/izumisy/go-cleanarchitecture:cache の箇所がInline cacheに該当する。SAVE IMAGE コマンドは --push オプションをつけることでリモートのコンテナレジストリにキャッシュとしてのイメージをpushする*2ようになる。なお、キャッシュのソースとなるイメージを別で指定する --cache-from というオプションもあり、キャッシュのpushとpullでそれぞれ別イメージを指定することもできる。

以上のEarthfileに対して以下のコマンドを実行すると、初回実行時はghcr.io/izumisy/go-cleanarchitecture:cacheにキャッシュイメージが作られ、以降は実行時にキャッシュイメージが使用される。

$ earthly --use-inline-cache --save-inline-cache --push +image

Explicit cache

上のInline cacheの例で抜粋したEarthfileのうち、SAVE IMAGE --cache-hintの箇所がExplicit cacheに該当するもの。

deps:
  RUN apk add --no-cache build-base
  COPY go.mod go.sum .
  RUN go mod download
  SAVE IMAGE --cache-hint

Earthlyの実行時には以下のように --remote-cache オプションでExplicit cacheを格納するDockerイメージの名前を指定する。こちらも --push オプションがあればキャッシュがコンテナレジストリへpushされる。

$ earthly --push --remote-cache=ghcr.io/izumisy/go-cleanarchitecture-explicit:cache +image

Explicit cacheはEarthfileを通してひとつしか存在しない。Inline cacheのようにEarthfile中に複数異なる名前でキャッシュイメージを指定することはできず、--cache-hint を指定しているすべてのターゲットのレイヤキャッシュが実行時に --remote-cache で向けられているキャッシュイメージへすべて格納される。

もしEarthlyの実行時にEarthfile内でExplicit cacheが指定されている場合には、Earthlyは一番最初にこのキャッシュを取得するような動作をする(たぶん)

Inline cacheとExplicit cacheの併用

自分が試した0.6.2時点では、ソースとしての併用はできるが--pushオプションで両方をpushすることはできない。

なので、自分のCircleCIの設定ではコンテナレジストリへのInline cacheイメージのpushはローカルから実行するようにし、CI上ではExplicit cacheのみをpushするようにしている。--use-inline-cacheのオプションはExplicit cacheと併用できる。

jobs:
  integration-test:
    machine: true
    resource_class: large
    steps:
      - run:
          name: Setup docker with ghcr.io
          command: |
            docker login ghcr.io --username "izumisy" --password "$DOCKERUSER_TOKEN"
      - run:
          name: Setup earthly
          command: |
            wget https://github.com/earthly/earthly/releases/download/v0.6.1/earthly-linux-amd64 -O /tmp/earthly
            chmod +x /tmp/earthly
            /tmp/earthly bootstrap
            /tmp/earthly --version
      - checkout
      - run:
          name: Run earthly
          command: |
            /tmp/earthly --use-inline-cache --push \
              --remote-cache=ghcr.io/izumisy/go-cleanarchitecture-explicit:cache \
              +integration-test

ローカルからのInline cacheイメージのpushをするコマンドは次のような雰囲気

$ earthly --push --save-inline-cache +images

*1:前回の記事で「あったらいいのに」とか書いたけど冷静に考えたらEarthlyの思想的にはありえなかった...

*2:ちなみに--pushオプションがなくてもローカルにはキャッシュが作られる

Earthlyでコンテナ時代のビルド環境を味わう

【これはUnipos Advent Calendar 2021の2日目の記事です】 https://github.com/earthly/earthly/raw/main/img/logo-banner-white-bg.png

つい最近、EarthlyというDockerコンテナベースのビルドツールで、自分の開発しているGoのアプリケーションのMakefile/Dockerfile/docker-compose.yamlを置き換えたのでそれを記事にしてみる。

Earthlyとは

github.com

めちゃくちゃ雑に言うとDockerイメージをベースにしたビルドツール。

できることとしてはGoogleが作っているBazelに近い*1が、書き味はMakefile+Dockerfileに近く*2、独特の文法が少ない雰囲気。当然、言語やフレームワークに依存しない。まるでローカル環境でビルドをしているかのようにシームレスにコンテナ環境でビルドを実行できる。

Earthlyは書き味こそDockerfileと似ているが、Dockerイメージを作ることが目的のDockerfileよりも汎用性が高い。Dockerイメージの作成以外にも、コンテナ環境でのテストの実行、ファイルの生成など、イメージの定義だけではなく実行するコンテナ上での操作も定義できる。この辺はMakefileのエッセンスを取り入れていると言える。

他にも、Dockerよろしくレイヤキャッシュがちゃんと使えたり、Makefileでいうターゲット定義の単位でビルドグラフを作ってくれて、勝手に並列にできそうなところは並列化したりしてくれたりと嬉しい要素はたくさんあるが、とにかくまずはEarthfileの雰囲気を見る方が早い。

Earthfileの雰囲気

実際のファイルはここに置いてある。 github.com

アプリケーションのビルド

ほとんどMakefileと雰囲気は同じ。このあたりはDockerfileで書かれていたところ。

FROMCOPYで依存関係を定義でき、依存するイメージやアーティファクトがなければ自動でターゲットを実行し成果物を生成する。ここでは earthly +image とやると上から順番にターゲットが実行され、最終的にアプリケーションのDockerイメージが作られる。

VERSION 0.6
FROM golang:1.17-alpine3.14
WORKDIR /go-cleanarchitecture

deps:
  COPY go.mod go.sum .
  RUN apk add --no-cache build-base
  RUN go mod download

build:
  FROM +deps
  COPY . .
  RUN go build -o build/go-cleanarchitecture main.go
  SAVE ARTIFACT build/go-cleanarchitecture AS LOCAL build/go-cleanarchitecture

image:
  COPY +build/go-cleanarchitecture .
  EXPOSE 8080
  ENTRYPOINT ["/go-cleanarchitecture/go-cleanarchitecture"]
  SAVE IMAGE go-cleanarchitecture:latest

開発時のミドルウェア立ち上げまわり

middlewares-up はもともとはdocker-compose.ymlでやっていた処理をearthlyに移植したもの。

LOCALLY コマンドを使うとホストのDocker上でコンテナが実行される。本当はホストではなくEarthlyが起動するコンテナ上ですべて実行したかったが、--net オプション指定でコンテナに接続させるネットワークを同じものにしても、なぜかdocker runを実行するターゲットが異なると動かなかった。というわけで、仕方なくホスト側で実行するようにしている。

# Development

run:
  LOCALLY
  WITH DOCKER --load app:latest=+image
    RUN docker run --net=go-cleanarchitecture-network --net-alias=app \
      -p 8080:8080 --env APP_ENV=production --rm app:latest -http
  END

middlewares-up:
  LOCALLY
  WITH DOCKER \
      --load db:latest=+db \
      --load redis:latest=+pubsub
    RUN docker network create go-cleanarchitecture-network && \
      docker run -d --net=go-cleanarchitecture-network --net-alias=db \
        --name go-cleanarchictecture-db -p 3306:3306 --rm db:latest && \
      docker run -d --net=go-cleanarchitecture-network --net-alias=redis \
        --name go-cleanarchictecture-redis -p 6379:6379 --rm redis:latest
  END

middlewares-down:
  LOCALLY
  WITH DOCKER
    RUN docker stop go-cleanarchictecture-db go-cleanarchictecture-redis && \
      docker network rm go-cleanarchitecture-network
  END

WITH DOCKER コマンドには --compose というdocker-compose.yamlを読み込むオプションも付いているが、0.6.1の段階ではearthlyがdocker-compose.yamlのネットワーク定義のセクションを読んでいないっぽく、ミドルウェアを立ち上げても +run で立ち上げたアプリケーションが繋ぎに行けない様子。今回はいったん諦めてdocker-compose.yamlを削除しearthlyで完結させることで解決した。

ちなみにdbやpubsubなどのミドルウェアの定義はこんな感じ。この辺はシンプルでいい。

# Middlewares

db:
  FROM mysql:5.7
  ENV MYSQL_ROOT_USER=root
  ENV MYSQL_ROOT_PASSWORD=password
  ENV MYSQL_DATABASE=todoapp
  EXPOSE 3306
  SAVE IMAGE db:latest

pubsub:
  FROM redis:6.2.6-alpine3.15
  EXPOSE 6379
  SAVE IMAGE pubsub:latest

E2Eテスト

integration-testも元々はdocker-composeでミドルウェアの立ち上げをするようにしていたところなので、なかなか激しい感じになっている。

上では書き忘れていたが WITH DOCKER の中では RUN コマンドをひとつしか配置できない(!)という制約が現状あり、そのせいで&&を多用せざるを得なくなっている。正直、この辺の書き味はまだまだなんともいえない。

integration-test:
  LOCALLY
  WITH DOCKER \
      --load db:latest=+db \
      --load redis:latest=+pubsub \
      --load migrater:latest=+migrater \
      --load dredd:latest=+dredd \
      --load app:latest=+image
    RUN docker network create test-network && \
      docker run -d --name=db --net=test-network --net-alias=db -p 3306:3306 --rm db:latest && \
      docker run -d --name=redis --net=test-network --net-alias=redis -p 6379:6379 --rm redis:latest && \
      while ! nc 127.0.0.1 3306; do sleep 1 && echo "wait..."; done && sleep 15 && \
      docker run --net=test-network --rm migrater:latest migrate && \
      docker run -d --name=app --net=test-network --net-alias=app -p 8080:8080 --env APP_ENV=production --rm app:latest -http && \
      while ! nc 127.0.0.1 8080; do sleep 1 && echo "wait..."; done && sleep 15 && \
      docker run --net=test-network -w /app --rm dredd:latest && \
      docker stop db redis app && \
      docker network rm test-network
  END

dredd:
  FROM apiaryio/dredd
  COPY . /app
  COPY api-description.apib dredd_hook.js .
  ENTRYPOINT dredd api-description.apib http://app:8080 --hookfiles=dredd_hook.js
  SAVE IMAGE dredd:latest

ほかにもキャッシュの機構だったり、エラーがおきたときにその場で起動中のコンテナにsshできるインタラクティブモードというやつがあったりするが、ここでは省略。公式ドキュメントがめちゃくちゃ整備されているので、それをみるのが早い。

追記: キャッシュの話だけ追加で記事書きました izumisy.work

所感

いいところ

  • DockerとEarthlyさえあればどこでも再現性の高いビルド環境が用意できる
  • EarthlyだけでMakefile/Dockerfile/docker-compose.yamlを代替できる

ビミョい、あるいは難しかったところ

  • ターゲットの依存関係でレイヤキャッシュが効く条件がパッとは分からなかった
    • COPY, FROM, BUILD の3つがあるが、FROM だとキャッシュが効かないことがありどういう条件でキャッシュが効くのかすぐには分からなかった。これは自分のDocker力の問題かもしれない。
  • LOCALLYWITH DOCKER を使うことで受ける暗黙的な制限がいろいろある
    • この辺は使ってみると体験できる。基本は使わない方がいいような気もする。
  • WITH DOCKER--compose オプションが提供するdocker-compose.ymlとの連携が弱い
    • ネットワーク指定だったりボリューム指定だったりがたぶん動かない気がする。そもそもEarthlyで全部やれるんだからdocker-compose.yamlいらないだろ、という話かもしれない。
  • インラインキャッシュ
    • Earthlyのおすすめキャッシュ機構としてインラインキャッシュというやつがあり、これを有効にするとDockerhubなどのコンテナ・レジストリにキャッシュイメージがpushされる。キャッシュがイメージ化されることで、イメージをpullできればあらゆる環境でキャッシュを利用できるというメリットがある。しかし一方で、たとえばDockerhubだと無料プランの人はrate limitがあったりするわけなので、無料で使い倒そうとするとrate limitに引っ掛からないようキャッシュを頻繁に更新したり取りにいったりしないようにする、みたいな工夫が必要になる。
    • 正直、個人的にはファイルキャッシュができると嬉しいがGithub Issueを見た感じあんまり盛り上がってなかった。業務開発ならクラウドプラットフォームで用意されているGCRとかAWS ECRあたりが使えるだろうから、実際にはrate limitとかは些細な問題なのかもしれない。
  • CI環境(CircleCI)ではたぶんインラインキャッシュキャッシュがないと実行時間が長くなる
    • docker-composeを使っていたときは特段キャッシュなどはしていなかった。それでもdocker-composeのほうが1.5倍くらい実行速度は速かった。
    • むしろCI上でインラインキャッシュを使わずEarthlyを使うなんてありえない、という話なのかもしれない。

まとめ

いいところの5倍くらい微妙なところを書いてしまった...

が、まだまだ発展途上なのでこれからの進化に期待。ポテンシャルはめちゃくちゃありそうなので、とにかくおすすめです。自分は結構ハマりそうな予感。

Firebase HostingのPreviewチャネルへデプロイしたURLをPRにコメントするGithub Actionsを作る

【これはUnipos Advent Calendar 2021の1日目の記事です】

PRを作ると自動でFirebase HostingのPreviewチャネルにデプロイが行われ、デプロイされたチャネルのURLがコメントされる。コミットが積まれるたびに新しくデプロイされ、コメントのURLも更新される。そういうGithub Actionsを最近作ったので備忘メモ。

f:id:IzumiSy:20211201130615j:plain
Github Actionsがつけるコメントの雰囲気

今回はFirebase Hostingを使っているので、やっていることはFirebaseExtended/action-hosting-deployとほぼ同じだけれど、これを応用すればFirebase Hostingに限らず他のPaaSサービスとでも同じようなやつをサクッと作れる気がする。

たとえばAppEngineにサービスをデプロイしてそのバージョンをコメントするみたいなやつとか(すでにありそうだけど...)

実装

本番のコードを乗せるわけにはいかないので雰囲気yamlです。

name: preview-deploy

on:
  pull_request:
    types:
      - opened
      - synchronize # コミットがあるたびに動くために必要

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    container: node:14-buster
    steps:
      - name: checkout
        uses: actions/checkout@v2

      # firebase hosting:channel:deployからURLを抽出するのに必要
      - name: install tree and jq
        run: |
          apt update
          apt install -y tree jq

      - name: build
        run: |
          npm install
          npm run build

      # Previewのデプロイ
      - name: deploy preview
        id: deploy-preview
        run: |
          TOKEN="your-own-firebase-token"
          PR_NUMBER=${{ github.event.pull_request.number }}
          PREVIEW_URL=$(npx firebase hosting:channel:deploy $PR_NUMBER --token=$TOKEN --project="your-project" --json | jq -r '.result."your-project".url')
          echo $PREVIEW_URL
          echo "::set-output name=preview_url::$PREVIEW_URL"

      # すでにPreviewのURLがコメントされていたらそれを見つける
      - uses: peter-evans/find-comment@v1
        id: find-comment
        with:
          issue-number: ${{ github.event.pull_request.number }}
          comment-author: 'github-actions[bot]'
          body-includes: Storybook preview has been deployed!

      # PRがオープンされた最初にだけPreviewのURLをコメントする
      - name: comments to PR
        uses: peter-evans/create-or-update-comment@v1
        with:
          comment-id: ${{ steps.find-comment.outputs.comment-id }}
          issue-number: ${{ github.event.pull_request.number }}
          body: |
            Storybook preview has been deployed! :shipit:
            :arrow_right: ${{ steps.deploy-preview.outputs.preview_url }}
          edit-mode: replace

peter-evans/find-comment@v1peter-evans/create-or-update-comment@v1 がめちゃくちゃ神Actionで、この二つを使えばPRコメントのupsertが実現できる。正直これが一番の要かも。

あとはFirebase CLI--json という隠れオプションがあるので、これを知っていないと自分でPreviewのデプロイをするActionは作れない... helpにもドキュメントにも書かれてなかった。 qiita.com

os.SameFileでファイルシステムに依存しないパス比較をする

earthly/earthlyに修正のPRを出す過程で知ったのでメモ github.com

例えば /Users/hoge/ccc/Users/hoge/CCC というふたつのパスがあるとする。

クロスプラットフォームCLIアプリケーションを作っていたりすると、上記のような2つのパスが「同じ場所を指しているか」を比較するにあたってMacOSLinux, Windowsファイルシステムでcase-sensitivityを考慮して実装したくなるかもしれない。

これを何も考えずに解決しようとするとこういうのが思い浮かぶ。

pathA := "/Users/hoge/ccc"
pathB := "/Users/hoge/CCC"

if runtime.GOOS == "darwin" {
    pathA = strings.ToLower(pathA)
    pathB = strings.ToLower(pathB)
}

if pathA == pathB {
    fmt.Println("path matched")
}

APFSはデフォルトではcase-insenstiveなので場当たり的によさそうではあるが、デフォルトでそうというだけであってフォーマット時にcase-sensitiveを選択することもできる。つまり、厳密にはOSを見るのではなくファイルシステムを考慮してcase-sensitivityを判別しないといけないことになる。

じゃあファイルシステムをチェックする実装を... するのはイヤなので、こういうときには os.SameFile という関数を使うのがよい。

os.SameFile によるパス比較

雑に書き換えてみるとこういう感じになる。

pathA := "/Users/hoge/ccc"
pathB := "/Users/hoge/CCC"

pathAInfo, _ := os.Stat(pathA)
pathBInfo, _ := os.Stat(pathB)

if os.SameFile(pathAInfo, pathBInfo) {
    fmt.Println("path matched")
}

os.SameFile は内部的にはgodocに書かれているように次の挙動をするとのこと。

For example, on Unix this means that the device and inode fields of the two underlying structures are identical; on other systems the decision may be based on the path names.

Unixな環境ではパス名を文字列で比較することはせずinodeによって比較をするらしい。それ以外の環境ではパス名による比較が行われる。嬉しい抽象化だね。