Runner in the High

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

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できるインタラクティブモードというやつがあったりするが、ここでは省略。公式ドキュメントがめちゃくちゃ整備されているので、それをみるのが早い。

所感

いいところ

  • 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によって比較をするらしい。それ以外の環境ではパス名による比較が行われる。嬉しい抽象化だね。

なぜElmは0.19のままか、変化すること/しないこと

discourse.elm-lang.org

つい先日、数か月ぶりにElmのupdate話がでてきた。

Elmは0.19からほとんどメジャーバージョンアップしていない。最後のリリースは約9か月前にもなる。

この事実だけを知ると「Elmはもう終わったのか」「Evan*1は開発のモチベーションを失ったのか」と思われることがある。実際そういう話はネットでチラホラ見かける。確かに、フロントエンド開発言語のAltJSとして近しいTypeScriptやFlutterと比較すると、あまりにも機能追加され無さすぎるようにも見える。究極的には「何と比較するか?」という話だとは思うが、たしかにフロントエンド界隈的な観点ではElmは亀の歩みなのは間違いない。

変化するのはいいことだ...

なんとなく肌で感じる人も多い事実として、世の中には"最先端を目指して変化するのはいいことだ"という暗黙的な統一見解が存在している。

プロダクト*2開発における変化というのは大抵の場合「新しく機能を追加する」という行為とイコールであり、バグ修正や保守関連開発のみをやっているプロダクトは「歩みを止めた」ものと捉えられる。従って、プロダクトへの新機能追加や長期に渡るロードマップの公開は"衆目を集めるパフォーマンスでもある"ということがまず前提として理解できなければここから先の話へは進めない。

パフォーマンス... これにはいろんな意味合いがある。たとえば、利用者が求める機能をガンガン取り込むことで「ユーザー主導のコミュニティである」とアピールすれば、OSSコントリビューションの経歴を作りたい開発者が集められるだろう。関数型プログラミングの文法を機能として取り入れれば、関数型プログラミングに興味のある開発者も集められるかもしれない。開発コミュニティのコアメンバーがロードマップと共に「我々はまだ歩みを止めるつもりはない」という宣言をすれば、企業から出資を得られるかもしれない。

また、スタートアップやヴェンチャーは人的リソースを確保するために変化の多い最先端な「開発者にとって人気のあるツール」を使うことで魅力をアピールし、ときにはスポンサードする。開発者もまた、目新しく最先端なツールを使う環境にいることで自らのキャリア価値向上をさせたい(そして高い給与を得たい)ので、目新しくエッジなツールを積極的に学び、そしてときには自らのアピールのためにコントリビュートする。

プログラミング言語フレームワークにおけるエコシステムはこのような形で発展する。一般的にはこういうものがいわゆる"民主的なOSS"だと言える。

変化しないElm

ではElmはどうか。おそらくElmはそのようなエコシステムを期待していない。

まず第一にElmは他と比較してまったくコミュニティが民主的ではない。ロードマップも特に決まったものを公開はしないというポリシーだし、コアメンバー(とは言ってもほぼEvan)以外が新機能を提案して追加したりすることは基本的にはありえない。*3

この思想の根本はどこにあるのか。少なくともコミュニティ主導によるOSS開発に対する解釈の違いがEvanの中にあるのは間違いない。以下のカンファレンストークでそのような思想に至った背景が垣間見える。

www.youtube.com

まず第一に「コミュニティからの要望」に対する動きがそもそも異なっている。

たとえば「カスタムオペレータを自前で実装できる文法が欲しい」という話がコミュニティから挙げられた際に、機能の実装するかどうかの判断は求める人間の数やユースケースによって考慮されるのではなく、既存の機能の応用やコミュニティ内の開発者同士によるサポートで解決が出来ないか促される。ほとんどこの時点で"ほしい機能"の議論は終わる。事実、Elm Discourseで観測される要望の大部分は、このような「既存でも問題ないがあると嬉しい」ようなものでしかない。とはいえ、一般的なOSSであれば、こういうときはRFCが立てられ賛同者の数やユースケースなどのインパクトを考慮した上で機能追加の議論がGithub Issueで始まったりするが、Elmではそのようなものはほとんど受け入れられない。

このようにElmがコミュニティ主導の機能変更に対して大手を振って対応しない理由は大きく3つの理由があると言える。

まずひとつめは、機能に対する根源的なモチベーションの解釈。新機能を追加したいというモチベーションがどのような課題解決に根差すものかをEvanは慎重に見ている。特にプログラミング言語における機能は「追加するのは簡単だが削除するのは難しい」ものが多くあり、機能がさらに増えるたびに別機能との整合性や、後方互換性などの考慮事項も芋づる式に増えていく。ある程度未来を見越したメンテナンスコストをかけてまでも追加したい機能というのは「あればいい」ではなく「なければ使えない」のレベルまで昇華しきったものに限定される。*4

次に、機能の責任に対する解釈。上の話ともつながる部分はあるが、仮にコミュニティ主導で機能が提案され追加されたとして、その機能が結果的にどういうインパクトをプログラミング言語の利用者に与えたか、そしてその機能に対してどういうアクションをとるか(さらに機能拡充するのか、削除するのか、それとも放置するのか)という長期的な責任の所在がコミュニティ主導な世界では維持されにくい性質があるとEvanは示唆している。新機能を作ることには熱心だが、一度作られた機能の生涯には誰も熱意を示さないというのは非常によくある話であり、業務でプロダクト開発をしている現場ですら起こりえる。となると、単なる内発的動機でOSS活動をしている人間の集団においては、誰もが煌びやかな新機能追加などの「オイシイとこどり」だけをしたい*5のは間違いない。とはいえ、OSSとして無償でコントリビュートしてくれている人間に長期的な責任の所在を求めることは当然理にかなった話ではない。

そして最後に、おそらくEvan自身のプログラミング言語に対する解釈もある。EvanはElmを教育用言語の位置づけで開発したこともあり、言語のシンプルさに対して大きな優先度を与えていることが推測できる。事実、0.16から現時点で最新の0.19までには少なくない言語仕様の変更があり、そのどれもが機能を減らすものだった。つまり今のElmはこれまでのElmの歴史の中で最も機能が少ないバージョンであり、自分自身も確かにこれ以上減らせる機能はないのではないかとすら思う。関数型言語という立て付けでありながら型クラスもモナドもない。ある程度は幽霊型のようなそれっぽい実装はできるが、それが限界というレベルのシンプルさが今のElmになっている。

トレーダーの間では「株価は下がるときよりも上がるときのほうが怖い」とよく言われるが、これはプログラミング言語についても同じことが言える。株価もプログラミング言語の複雑さも下限はあっても上限はない。コントロールしなければいくらでも言語自体の複雑さから周辺のツールチェインまでいくらでも複雑になるし、ほとんどの場合機能を増やす側は良かれと思って機能を追加しているので誰かが止めなければいくらでも機能は増え続ける。どれとは言わないがそういうプログラミング言語はたくさんある。教育用言語として始まったElmにとって機能拡充とそれに伴う複雑性の増加が直行する方向性である*6ことは間違いない。

思想の違い

超ざっくり要約するならば、TypeScriptやFlutterなどのイケイケなやつがシリコンバレーであり、Elmはアーミッシュの村のような立ち位置にある。派手さやダイナミックな変化はないが、慎重でサステイナブルではある。変化はしないが死ぬつもりはない。

とはいえ、Elmの理想とする思想が持続するか/スケールするかと言われると、結局は理想の話でしかない。現実世界では利益を出せとプレッシャーをかけられている組織のほうが必死であり、結果的には大きな影響力を持つ。資本が注入されていればいるほど絡む人間や組織も増えるし、それに伴って利益を得たいというモチベーションが増幅される。金でドライブされたモチベーションによってマーケティングにもより一層力がかかる。金の流れとコミュニティの勢いは比例する。シリコンバレーアーミッシュなら、シリコンバレーのほうが圧倒的にみんな必死でありそれに引き寄せられて金と人が集まってくるのは間違いない。これをどう捉えるかは、個々人の解釈次第だ。

このようなElmの根本的にある思想はある意味ではナイーブで繊細でもあり、別の見方をすれば現代資本主義に対するアンチテーゼですらある。レッセフェールな市場のメカニズムが中心となって生まれる技術の発展や進歩は大部分で世の中の我々の生活を良くした。しかし一方で、利益重視なカネ駆動モチベーションが個別最適的で意図しない方向に集団を突き動かしているという話はIT界隈でいくらでも見聞きできるだろう。

こういう話をもっと知りたければ以下の本がおもしろかった。もしかしたら、ソフトウェア・エンジニアとしての桃源郷*7シリコンバレーにあるかどうか分かるかもしれない。

*1:Elmの作者

*2:ここではElmなどのプログラミング言語フレームワークなどを包含してプロダクトと表現することにする

*3:この件でかつて「ElmはEvanの独裁制だ」というようは批判の記事がredditで盛り上がったことがある。見方によってはたしかにそうだ。

*4:なおElmにはポートと呼ばれるFFI相当の機能があるので、JavaScriptでできることはElmでもできてしまう。「〇〇ができる機能が欲しい」という要望に対して「ポートじゃできないのか?」という問いにMust-haveで返せる事例は少ない。これを誠実な対応と捉えるか、不誠実な対応と捉えるかは利用者次第だ。

*5:既存機能の改修って新機能開発よりもキラキラ感が少ない割に、過去の歴史を遡ったりや技術的負債に手を入れないといけなかったり色んな調整が必要だったりでおいしさが少ない感があるのはめっちゃわかる。

*6:機能の少なさだけではなくエラーメッセージの分かりやすさを重視する変更が何回か過去にリリースされたり、コミュニティおいてビギナーに対するポリシーやインクルージョンを重要視する姿勢もこの辺にかかってくる。

*7:この解釈も各々にゆだねる

docker-composeを使う際のprofiles, networks, volumes

go-cleanarchitectureにdocker-composeを導入した過程で学んだprofiles, volumes, networksの話。

https://github.com/IzumiSy/go-cleanarchitecture/blob/master/docker-compose.ymlgithub.com

profiles

ざっくり言うと、サービスをグループ化して部分的に起動できる。

r7kamura.com

そもそも、開発中のアプリケーションという"コロコロと中身の変化するもの"をDockerイメージにするのはあまりいいやり方ではない。少しでもコードが変更されるたびにdocker buildが必要で効率が悪い。なので、開発環境としてdocker-composeを使う場合にはミドルウェアだけをservicesとしてまとめて起動したほうがいい。

ただ、いかんともしがたい理由で開発中のアプリケーションもdocker-composeのサービスとして含めたいこともある。つまり、ローカルでアプリケーションを起動したいこともあればdocker-composeのイメージとしてアプリケーションを起動したいこともある、という場合にはprofilesが非常に便利。毎回、利用するprofileを指定する手間は若干あるが、逆に言えばそれだけでいい。

volumes

DockerでMySQLなどに対してボリュームを使いたい場合にはvolumesセクションで driver: local なボリュームをあらかじめ作っておくといい。

# 以下で作ったボリュームは "mysqlvolume:/var/lib/mysql" みたいな感じで使える。

volumes:
  mysqlvolume:
    driver: local
  redisvolume:
    driver: local

ネットに転がっている記事では "./tmp/data/db:/var/lib/mysql" のような記述でパス指定したボリュームをサービスに対して与えているものがあるが、この指定では ./tmp ディレクトリのownerがrootになってしまう。すると、全サービスをdocker-composeで再起動したときに permission denied なエラーがでて困る。

この権限周りとボリュームの話はよくあるエラーのようで、docker-composeを実行するときにユーザーを明示的に指定するなどの解決策があるらしい。が、最も簡単な解決策は明示的にホスト側のパスを指定せずボリュームを作ることだと思う。

元ネタとなる回答は以下のstackoverflowにある。

stackoverflow.com

networks

docker-composeはデフォルトでそれっぽいネットワーク名をつけてくれるが、明示的にネットワーク名を指定すると便利なこともある。

# 以下で作ったネットワークは各サービスで app-network として指定すれば使える

networks:
  default:
    external:
      name: bridge
  app-network:
    name: go-cleanarchitecture-network
    driver: bridge

たとえば自分のアプリケーションではマイグレーションにflywayを使っていて、ローカルにインストールせずDockerイメージとして起動するようにしている。この場合docker-composeで起動しているサービスに対してflywayのイメージが繋ぎにいくことになるわけだが、起動しているDockerコンテナ間で接続を行う際にはネットワーク名を指定しないといけない。

他にも、APIサーバに対するインテグレーションテストでdreddを利用していて、こちらも同様にDockerイメージとして利用している。

test/integration:
  docker run --net=go-cleanarchitecture-network --rm -it -v "$$(pwd):/app" -w /app apiaryio/dredd dredd \
      api-description.apib http://app:8080 --hookfiles=./dredd_hook.js

db/migrate:
  docker run --net=go-cleanarchitecture-network --rm -v "$$(pwd)/schemas/sql:/flyway/sql" -v "$$(pwd)/config:/flyway/config" \
      flyway/flyway -configFiles=/flyway/config/flyway.conf -locations=filesystem:/flyway/sql migrate

db/clean:
  docker run --net=go-cleanarchitecture-network --rm -v "$$(pwd)/schemas/sql:/flyway/sql" -v "$$(pwd)/config:/flyway/config" \
      flyway/flyway -configFiles=/flyway/config/flyway.conf -locations=filesystem:/flyway/sql clean

docker-composeでグルーピングされたサービスとしては扱いたくないが局所的にテストやマイグレーションなどで使用する別のDocker-basedなツールがある、というケースであればネットワーク指定はあったほうが便利だと思われる。docker-composeで自動で付与されるネットワーク名を予想してもいいが、自分で指定したほうが確実。

CloudBuildのキャッシュに便利なgcs-cachingというDockerイメージを作った

github.com

CloudBuildではこんな感じで使える。

steps:
  # キャッシュの保存
  - name: izumisy/gcs-caching:latest
    args: ["store", "./caching-directory", "gs://your-own-build-cache", "./cachekey-file"]
  # キャッシュの取り出し
  - name: izumisy/gcs-caching:latest
    args: ["restore", ".", "gs://default-build-cache", "./cachekey-file"]

公式のドキュメントだと「キャッシュの読み書きには gcr.io/cloud-builders/gsutil を使え」みたく書いてあるが、リアルなユースケースではディレクトリを固めてキャッシュキーを計算して、みたいな処理もある。

そういうことをしようと思うとgsutilだけだとプリミティブ過ぎて微妙なので作ってみた。

m4a(mp4)におけるFPS計算

FPSググると動画の話ばっかり出てくるが、デコーダを扱うには音声でも秒間フレームサイズが知りたくなることがある。

「秒間」というくらいなので、まずはm4a(mp4)における再生時間データを計算する方法が必要。以下の式がそれにあたる。DurationもTimescaleもどちらもmdhdというBoxに格納されていて、計算結果は秒数で出る。

再生時間 = トラックの長さ(Duration) / トラックのタイムスケール(Timescale)

あとは上で計算されたデータでstszに格納されているフレームの総数を割るだけでいい。

FPS = フレーム数(stsz) / 再生時間

ここまで計算できれば「時間あたりmdat上のバイナリサイズがどれくらいか」なども出せるようになるので、たとえば「m4aファイルから時間指定のオフセットで90秒分だけwavデコードする」などの処理も実装できるようになる。

GCSのReader実装を拡張してio.Seekerを実装する

という実装のコード片をGithubのissueコメントから拾ったので自分で完成版を作ってみた。

内部的にNewRangeReaderを何度も呼び出しているのでストレージに対するReadの量は増える可能性があるのでそれだけ注意したい。

もしもシーク位置が比較的局所的な仕様ならばsunfish-shogi/bufseekioという便利なものがあるので、これでラップしてやるとReadトラフィックが増えるみたいな懸念はある程度払しょくできるかもしれない。

package storage

import (
    "context"
    "errors"
    "io"

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

var _ io.ReadSeekCloser = &SeekableReader{}

type SeekableReader struct {
    object   *storage.ObjectHandle
    ctx      context.Context
    reader   *storage.Reader
    offset   int64 // initial offset
    fileSize int64 // if this is known and set, it enables io.SeekEnd
}

func NewSeekableReader(ctx context.Context, object *storage.ObjectHandle) (*SeekableReader, error) {
    metaReader, err := object.NewRangeReader(ctx, 0, 0)
    if err != nil {
        return nil, err
    }
    if metaReader.Attrs.Size <= 0 {
        return nil, errors.New("fileSize should not be zero or negative value")
    }
    fileSize := metaReader.Attrs.Size

    return &SeekableReader{
        object:   object,
        ctx:      ctx,
        reader:   nil,
        offset:   0,
        fileSize: fileSize,
    }, nil
}

func (r *SeekableReader) Read(p []byte) (int, error) {
    var err error

    if r.reader == nil {
        r.reader, err = r.object.NewRangeReader(r.ctx, r.offset, int64(len(p)))
        if err != nil {
            return 0, err
        }
    }

    n, err := r.reader.Read(p)
    if err != nil {
        return 0, err
    }

    return n, nil
}

func (r *SeekableReader) Seek(offset int64, whence int) (int64, error) {
    var newOffset int64

    switch whence {
    case io.SeekStart:
        newOffset = offset
    case io.SeekCurrent:
        newOffset = r.offset + offset
    case io.SeekEnd:
        newOffset = r.fileSize - offset
    }

    r.Close()
    r.reader = nil
    r.offset = newOffset
    return r.offset, nil
}

func (r *SeekableReader) Close() error {
    if r.reader != nil {
        return r.reader.Close()
    }
    return nil
}