Runner in the High

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

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

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

github.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
}

JMOOCの「クラウドサービス・分散システム」の講座がいい感じ

内容的には自分が過去記事で紹介した「データ指向アプリケーションデザイン」の内容を、もう少しとっつきやすくかみ砕いたような雰囲気。大学での講義を収録したっぽいものなので、ある程度初めての人でも分かりやすいかもしれない。

lms.gacco.org

この講座の中で触れられているトピックは

  • 分散コミット(2層コミット, 3層コミット)
  • 定足数(クオラム, ビザンチン合意)
  • 論理クロック
  • データ複製の一貫性(強整合性, 因果整合性, 弱整合性, 結果整合性, ...)
  • IaC(Infrastructure as Code)

などが中心になっている。

定足数や論理クロックのあたりは、実際にアプリケーション開発をやっていて必ず登場する概念ではないが、クラウドプラットフォームから提供されるデータベース製品の仕様だったり、オーケストレータの挙動を理解するためにはある程度知っている方がいいのかなと思う。

一貫性回りの話は、DynamoDBだったりSpannerだったりいわゆるNoSQL周りを使うならばもはや必須の知識と言える。自分の場合は、社会人になってからプロダクト開発の都合上GoogleのDatastoreを触るようになった関係で先輩から整合性に関して細かく手ほどきを受けたけれど、こういう話を学生の頃から学べるのはちょっとうらやましい。

Goでm4a/mp4のraw aacなデータをデコードする

github.com

fdk-aacとそのgoバインディングgo-fdkaac*1を使ってやってみたので、試行錯誤過程のメモ。

ffmpegみたいなツールを用いた変換方法はネット上にゴロゴロしているのに、スタンドアロンなライブラリを使ってデコードをするとなると情報がめちゃくちゃ限られてくる。

特にaacデコーダ実装はデータ部にADTSというフレーム情報を保持するヘッダがあることが前提の話ばかりで、m4a/mp4に含まれるraw aacというヘッダ情報のないaacのデコードに関する情報はかなり見つけるのが難しい。

m4aとraw aacの関係性

まず大前提としてmpeg4の構造を知る必要がある。以下の記事が詳しかった。

qiita.com

データ構造のうちmdatと呼ばれる部分にaacなデータが含まれているが、これがADTSヘッダを持たないraw aacと呼ばれるものになっている。なので、単純なデータ操作(例えばddコマンドなど)でmdatからデータを抜き出してaacファイルとして再生しようとしても、これでは単なる不正なデータとして扱われてしまう*2。ADTSヘッダがないということは、当然代わりの相当するデータがm4aのデータ構造の中に書き込まれているはずで、m4aで言うとstblがそれにあたる。

特にfdk-aacにおいてはフレームサイズというのが非常に重要で、結果を受け取るためにデコーダのメソッドへ参照渡しされるバイト配列のサイズがフレームサイズとイコールで扱われているため、アロケーションされているバイト配列のサイズがフレームサイズと異なっているとエラーがでてしまいデコーダを動かすことができない。フレームサイズが1024とか2048みたいな固定長のサイズならまだしも、実際にはストリーム中のフレームサイズは数バイト単位で変動するため「どのフレームが何バイトか」という情報がなければデコード処理は実行できないということになる。

ADTSであればヘッダ情報の一部としてフレーム長が含まれているのだが、m4aではraw aacなのでそうはいかない。ではどうすればいいのかというと、ADTSヘッダの代わりにstbl配下のstszに格納されているフレームサイズの情報を参照することになる。

デマルチプレクサ

まったく専門外なので正しいことを言えているかは微妙だが、どうやらメディアデータの変換周りではデマルチプレクサと呼ばれるものが基本的には必要らしい。たとえばlibavformatが最もオールインワンなものとして有名っぽい。libav以外にも、いろんな人があるフォーマットに特化したデマルチプレクサを作っていたりする。Node.jsで動くts/mp4/flv用のデマルチプレクサもあった。

Goにもlibavのバインディング実装(imkira/go-libav)があり、そこそこ頑張っているっぽかった。しかし、libavの機能をcgoでラップするという壮大な世界観がメンテしきなかったのかここ数年は放置されているのが現状で、getしてみると実際にはビルドが通らず使いモノにならなかった。もしもlibavを使ってデコーダを実装するなら、自前でcgoを使ってラッパーを書くほうがましかもしれない。aacデコーダも内蔵されているので、デコーディングライブラリに拘らないのならばfdk-aacを使う必要すらない。

いずれにしても今回のケースではm4a/mp4用のデマルチプレクサだけが使えればよく、そのためだけにlibavを使うのはライブラリ自体を使うために求められる知識が多すぎて疲弊してしまう。

mp4リーダー

内部的にやっていることはlibavと同じなのかもしれないが、もう少しmp4に特化したものがある。例えばサイバーエージェントからはabema/go-mp4がでているし、他にもalfg/mp4というやつもある。どちらのライブラリも、上のQiita記事で解説されているatomsの情報をmp4のバイナリデータからパースして、ある程度参照しやすいような形(構造体とか)に変換してくれる機能*3を持っている。

これとfdk-aacを組み合わせれば、raw aacのデコードに必要な情報(フレームサイズ)をstszから読み取ることができる。今回はalfg/mp4の方を使ってみた*4

stszからフレームサイズを取り出す部分の実装は次のようになる。

// stszセクションから取り出したraw aacのフレームサイズ情報を保持する構造体
type frameSizes struct {
    frameOffset uint
    size        uint
    buffer      []byte
}

// atom.Mp4Readerを用いてstszセクションのデータを読み取り、ヘッダをスキップしたデータ部をframeSizes構造体として抜き出す
func newFrameSizes(reader *atom.Mp4Reader) (*frameSizes, error) {
    const stszHeaderOffset = 12 + 8 // stszのヘッダ部分をskipする分のオフセットサイズ

    stsz := reader.Moov.Traks[0].Mdia.Minf.Stbl.Stsz
    stszBuffer := make([]byte, stsz.Size)st
    if _, err := io.NewSectionReader(
        stsz.Reader.Reader,
        stsz.Start+int64(stszHeaderOffset),
        stsz.Size-int64(stszHeaderOffset),
    ).Read(stszBuffer); err != nil {
        return nil, err
    }

    return &frameSizes{
        frameOffset: 0,
        buffer:      stszBuffer,
    }, nil
}

// stszのデータ部にはビッグエンディアンで4バイトごとのデータとして格納されている
// binary.BigEndian.Uint32で変換してint64に変換することで10進数データとしてフレームサイズが計算できる。
func (v *frameSizes) Next() *int64 {
    const uint32byteSize = 4

    if len(v.buffer) < int(v.frameOffset) {
        return nil
    }

    frameSize := int64(binary.BigEndian.Uint32(v.buffer[v.frameOffset : v.frameOffset+uint32byteSize]))
    v.frameOffset += uint32byteSize
    return &frameSize
}

Nextメソッドで次のフレームサイズが計算されて返される間、forループでデコード処理を継続する。

以下抜粋。

offset := int64(mdatOffset)

for {
    nextFrameSize := frameSizes.Next()
    if nextFrameSize == nil {
        break
    }

    // 計算されたフレームサイズのぶんだけmdatからデータを読み取る
    part := make([]byte, *nextFrameSize)
    readCount, err := io.NewSectionReader(v.Mdat.Reader.Reader, offset, *nextFrameSize).Read(part)
    if err == io.EOF {
        break
    } else if err != nil {
        panic(err)
    }

    // mdatから読み取ったデータに対してデコード処理を実行する
    if pcm, err = d.Decode(part[:readCount]); err != nil {
        panic(err)
    }

    offset += *nextFrameSize
    if len(pcm) == 0 {
        continue
    }

    pcmWriter.Write(pcm)
}

go-fdkaacのDecodeメソッドはフレームサイズが正しくないと0x4002(AAC_DEC_PARSE_ERROR)をエラーコードとして吐き出してくるが、具体的に何が原因なのかを教えてくれないので非常に難しい。

fdk-aacソースコード中にあるエラーコードのコメントはほとんど参考にならないので、エラーが出てしまうとC言語で書かれたfdk-aacの実装を読むほかなくなってしまう。が、もちろんソースコードを読んでわかることの方が少ない...

ASC(Audio Specific Config)の値について

raw aacのデコードでフレームサイズの次に重要なのがASC(Audio Specific Config)と呼ばれる16進数値の組み合わせだ。

ADTSヘッダを持つaacなデータであれば、デコードに必要なオーディオタイプ、サンプリング周波数、チャネル設定などがすべてADTSに保存されているが、raw aacの場合にはデコーダに対して明示的に対象のraw aacをどのようにデコードするのか伝えてやらねばならない。

今回のコードで言うと、以下のデコーダを初期化する箇所で与えている16進数のデータがそれにあたる。

// AAC LC/44100Hz/2channelsなASCの設定でfdk-aacのデコーダを初期化
// (Ref: https://wiki.multimedia.cx/index.php/MPEG-4_Audio#Audio_Specific_Config)
d := fdkaac.NewAacDecoder()
if err := d.InitRaw([]byte{0x12, 0x10}); err != nil {
    panic(err)
}
defer d.Close()

この16進数はビットフラグでデコードの設定を表しているもので、ASCのビットフラグの説明は以下のものしかネットでは見つからない。

5 bits: object type
if (object type == 31)
    6 bits + 32: object type
4 bits: frequency index
if (frequency index == 15)
    24 bits: frequency
4 bits: channel configuration
var bits: AOT Specific Config

もちろんこのビットフラグを自分で組み立ててもいいが、自分でASCのビットフラグを組み立てなくてもffprobeを使えばmp4/m4aに含まれるaacのASCを知ることができる。

$  ffprobe -show_data -show_streams -hide_banner default.m4a

... 省略 ...

[STREAM]
index=0
codec_name=aac
codec_long_name=AAC (Advanced Audio Coding)
profile=LC
codec_type=audio
codec_time_base=1/44100
codec_tag_string=mp4a
codec_tag=0x6134706d
sample_fmt=fltp
sample_rate=44100
channels=2
channel_layout=stereo
bits_per_sample=0
id=N/A
r_frame_rate=0/0
avg_frame_rate=0/0
time_base=1/44100
start_pts=0
start_time=0.000000
duration_ts=2015987
duration=45.713991
bit_rate=128424
max_bit_rate=128424
bits_per_raw_sample=N/A
nb_frames=1970
nb_read_frames=N/A
nb_read_packets=N/A
extradata=
00000000: 1210 56e5 00                             ..V..

DISPOSITION:default=1
DISPOSITION:dub=0
DISPOSITION:original=0
DISPOSITION:comment=0
DISPOSITION:lyrics=0
DISPOSITION:karaoke=0
DISPOSITION:forced=0
DISPOSITION:hearing_impaired=0
DISPOSITION:visual_impaired=0
DISPOSITION:clean_effects=0
DISPOSITION:attached_pic=0
DISPOSITION:timed_thumbnails=0
TAG:language=und
TAG:handler_name=SoundHandler
[/STREAM]

ffprobeから出力される結果のうち以下の箇所がASCになっている。

extradata=
00000000: 1210 56e5 00                             ..V..

このデータのうち 1210 という箇所を 0x12 0x10 にしてやればよい。先頭のふたつだけがASCになる。

ffprobeでこういうデータが分かるということは、おそらくmp4中のどこかのatomに格納されているのかもしれない。が、今回はそれは調べていない。

*1:一か所だけリンカエラーで動かないところがあったのでforkしてエラーを直したやつ

*2:どうやら頑張ればバイナリデータのパターンからフレームデータのチャンクを識別できたりするらしいのだが、余計に情報が少なく泥沼に入ってしまいそうな気がしたのでやっていない。ネットの情報を見ているとfaadというaac用のデコーダはraw aacをADTS付のaacにデコードできるらしい。おそらく、フレームデータを直接エスパーしているのだろう。

*3:こういうのもデマルチプレクサっていうのかな?

*4:こちらもstszの読み取りに対応していなかったので今回はIzumiSy/mp4としてフォークした。

fdk-aacでM4A→WAV変換サンプル実装のビルド方法

izumisy.work

上記の記事のビルドをやっている前提

# libavformatは必須らしい
$ sudo apt install libavformat-dev

# fdk-aac配下でサンプルがあるブランチに切り替え
$ cd fdk-aac
$ git checkout decoder-example

# m4a-dec.cをビルド
# (https://github.com/mstorsjo/fdk-aac/issues/66#issuecomment-304263604から拾ったやつ)
$ gcc m4a-dec.c -o m4a-dec wavreader.c wavwriter.c \
    -I./libAACdec/include -I./libAACenc/include \
    -I./libSBRdec/include -I./libSBRenc/include \
    -I./libMpegTPDec/include -I./libMpegTPEnc/include \
    -I./libFDK/include  -I./libSYS/include ./.libs/libfdk-aac.a -I./libPCMutils/include \
    -lm -Wdeprecated-declarations -lavcodec -lavformat -lavutil

あとは m4a-dec というファイルが実行できればok

Ubuntu環境でのfdk-aacビルド方法

github.com

リポジトリにはREADMEどころか何も書かれていないのでメモしておく

# ビルドに必要なパッケージをインストール
$ sudo apt install autoconf libtool

# リポジトリをクローン
$ git clone git@github.com:mstorsjo/fdk-aac.git

# ビルド
$ cd fdk-aac
$ bash autogen.sh
$ ./configure --prefix=`pwd`/objs
$ make 
$ make install