減量終了

2021年7月から減量を始めて、1年経過した。一旦終了することにしたので結果を記載する。

開始日(2021-07-01)の体重は70.6kg、終了日の体重は62.0kg。8.6kgの減量となった。体重の推移は以下の通り。

1年経過したと書いたが、順調に体重が減ったのは2021年10月あたりまでで、その後は増えたり横ばいだった。ダラダラ続けてもこれ以上減らすのは厳しそうなので一旦終了することにした。

基本的に食事によるカロリー摂取量の管理とウェイトトレーニングしかしていない。一応散歩はしていたが、減量目的ではない。体重62~3kgあたりから減らすのが難しくなってきて、これ以上同じやり方で減らそうとすると生活に支障が出そうだなと思った。後、結果的に1年くらいやったことになるけど後半はモチベーションも下がってあんまりちゃんとできてなかったので、半年くらいの期間で計画してやるのがよさそう。

1週間くらい好き放題食べて、そこから増量プロセスに入っていくつもり。ウェイトトレーニングの重量もそこで上げていきたい。

AWS Lambdaで自前のコンテナイメージを動かす場合は/etc/hostsに注意

まとめ

  • AWS Lambdaでコンテナイメージを使う場合、使うイメージと動かすコードによっては No such file or directory @ rb_sysopen - /etc/hosts エラーになる可能性がある
  • 自前でイメージをビルドしていて、かつコードが /etc/hosts を読むような動きをしていると上記のエラーが発生する(基本的に /etc/hosts はイメージに含まれないため)
  • AWSが提供する https://gallery.ecr.aws/lambda/ruby のイメージを使う場合は問題ない( /etc/hosts が含まれている)
  • 回避策としては、Dockerfile内でCOPYを使い空のhostsファイルをイメージに追加する方法がある

事件発生

筆者が所属しているReproには、AWSのStepFunctionからRailsのコンテナイメージをLambda Functionとして呼び出し、Rakeタスクを実行するという実装がある( Ruby on Rails on Lambda – Speaker Deck )。

そのLambda Functionがある日突然動かなくなった。エラーログを見ると、 /etc/hostsを読もうとしているがファイルが存在していない。どういうことだろうか?もちろん、昨日までは元気に動いていたし、関連するコードを触った形跡はない。

/opt/ruby/lib/ruby/3.0.0/resolv.rb:192:in `initialize': No such file or directory @ rb_sysopen - /etc/hosts (Errno::ENOENT)
from /opt/ruby/lib/ruby/3.0.0/resolv.rb:192:in `open'
from /opt/ruby/lib/ruby/3.0.0/resolv.rb:192:in `block in lazy_initialize'

暫定対処

そもそもなんで /etc/hosts を読もうとしているのか、スタックトレースを見てみると Resolve#getaddress を読んでいた。

Resolv.getaddress(HOST)

これはデフォルトだとhostsファイルとDNSを参照しているため、 /etc/hosts を読もうとしていたというわけだ。ひとまず、暫定対処としてResolvが /etc/hosts を読まないように initializerで制御することとした。

if ENV["AWS_EXECUTION_ENV"]&.start_with?("AWS_Lambda_")
  Resolv::DefaultResolver.replace_resolvers([Resolv::DNS.new])
end

これでDNSのみ使うようになるので、ファイルがなくてエラーになることはなくなる。

原因を探る

暫定対処が完了したので、なぜ /etc/hosts がなくなったのかを調べる。Reproではコンテナイメージのビルド用にEC2インスタンスを用意していて、このトラブルの前にビルド用インスタンスを入れ替えていた。タイミングとしてはこれが怪しい、ということで旧ビルドサーバーと新ビルドサーバーでイメージを作って比較すると、確かに旧ビルドサーバーでビルドしたイメージには /etc/hosts ファイルが含まれるが新ビルドサーバーでビルドしたイメージには含まれない。

では、新旧の違いは何だろうかと考えるとき、一番の可能性として挙げられるのはdockerのバージョンだろう。

旧ビルドサーバーは結構昔に作られたもので、dockerのバージョンは 19.03.13-ce 。新ビルドサーバーは 20.10.13 と差があることがわかった。

Server:
 Engine:
  Version:          19.03.13-ce
  API version:      1.40 (minimum version 1.12)
  Go version:       go1.13.15
  Git commit:       4484c46
  Built:            Mon Oct 12 18:51:50 2020
  OS/Arch:          linux/amd64
  Experimental:     true
 containerd:
  Version:          1.4.1
  GitCommit:        c623d1b36f09f8ef6536a057bd658b3aa8632828
 runc:
  Version:          1.0.0-rc92
  GitCommit:        ff819c7e9184c13b7c2607fe6c30ae19403a7aff
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0
Server:
 Engine:
  Version:          20.10.13
  API version:      1.41 (minimum version 1.12)
  Go version:       go1.16.15
  Git commit:       906f57f
  Built:            Thu Mar 31 19:21:13 2022
  OS/Arch:          linux/amd64
  Experimental:     true
 containerd:
  Version:          1.4.13
  GitCommit:        9cc61520f4cd876b86e77edfeb88fbcd536d1f9d
 runc:
  Version:          1.0.3
  GitCommit:        f46b6ba2c9314cfc8caae24a32ec5fe9ef1059fe
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

また、最小構成で再現させるためのDockerfileを検証したところ、以下のように何かRUNが一つでもあると再現することがわかった。

# syntax = docker/dockerfile:experimental

FROM debian:bullseye

RUN ls -la

さらに、DOCKER_BUILDKIT=1などでBuildkitを使うと再現することもわかった。つまり、docker 19.03.13-ce が使うBuildkitとdocker 20.10.13 が使うBuildkitで挙動が違うのではないかと推測できる。

それぞれが使うBuildkitのバージョンを調べると、旧ビルドサーバーでは v0.6.4-32-gdf89d4dc を、新ビルドサーバでは v0.8.3-4-gbc07b2b8 を使っていた。

github.com/moby/buildkit df89d4dcf73ce414cd76837bfb0e9a0cc0ef3386 # v0.6.4-32-gdf89d4dc

https://github.com/moby/moby/blob/v19.03.13/vendor.conf#L29

github.com/moby/buildkit bc07b2b81b1c6a62d29981ac564b16a15ce2bfa7 # v0.8.3-4-gbc07b2b8

https://github.com/moby/moby/blob/v20.10.13/vendor.conf#L36

このバージョン間の差分を調べると、0.8.0で入ったclear file mount stubs and fix empty layer cases by tonistiigi · Pull Request #1739 · moby/buildkit が原因であることがわかった。このPullRequestより前だとRUNステップが一つでも実行されると空の /etc/hosts ファイルがイメージに残っていたようだ。このPullRequestによってRUN ステップの最後に /etc/hosts が削除されるようになっており、それによりコンテナイメージに含まれなくなっていた、ということだった。

/etc/hosts を含んだコンテナイメージを作る

なるほど、原因はわかった。ではどうするのがよいか。暫定対処で行ったResolverの置き換えは認識していないと意図せぬ挙動につながりそうなので、最後の手段としたい。そうするとlambda function実行時に /etc/hosts を作るか、コンテナイメージに /etc/hosts を置くかのどちらかになる。

Lambda Function実行時に /etc/hosts を作る

例えば、entrypointで touch /etc/hosts を実行して空のhostsファイルを作ればよいのではないか?と思ったが、Read Only Filesystemのため書き込むことができない(/etc/hosts のみ書き込み可能にできたりするんですかね?)。

コンテナイメージのビルド時に /etc/hosts を仕込む

起動時がだめならビルド時だ、ということでDockerfile内でなんとか /etc/hosts を配置できないか試行錯誤する。

RUNステップでtouchすればいいんじゃないかと思ったが、やはりRead Only Filesystemでだめ。

RUN touch /etc/hosts

最終的にどうしたかというと、COPYを使った。そもそも、AWSが提供しているイメージでには /etc/hosts が含まれているため同様の問題は発生しない。じゃあどうやって /etc/hosts を追加しているかというと、ADDを使ってtar.xzファイルを追加している( https://github.com/aws/aws-lambda-base-images/blob/ruby2.7/Dockerfile.ruby2.7 )。

なので、空のhostsファイルを用意して、Dockerfile内で /etc/hosts として追加すればイメージに /etc/hosts を含むことができる。

COPY docker/hosts /etc/hosts

ちなみに、COPYとADDどちらでも動くが、COPYを使うほうが望ましい。aws-lambda-base-imagesではtar.xzファイルを自動展開するためにADDを使っているが、今回のケースでは展開が不要である。なので、より挙動が明確なCOPYを使うとよい( ADD と COPY )。

これで、Buildkitによって空の /etc/hosts ファイルがイメージに残っていた時と同じ状態にでき、暫定対処のコードがなくても動作するようになった。

そういうわけで、AWS Lambdaで自前のイメージを動かす場合はコードによっては /etc/hosts ファイルを読もうとしてエラーになることがある、という話でした。

Observability Conference 2022におけるライブ・アーカイブ配信を支える技術 AWS・アプリケーション編 #o11y2022

本投稿では、2022年3月11日に開催されたObservability Conference 2022 (以後、o11y2022)におけるライブ配信・アーカイブ配信をどのように実現したのか、主にRailsアプリケーションとAWSサービスについて紹介します。なお、OBSなどの配信基盤についてはこの投稿では触れません。

実現したいこと

o11y2022の配信アーキテクチャでは以下の3点の実現を目指しました。

  • ライブセッションをできるだけ低遅延で配信する
  • 事前録画セッションを配信する
  • ライブセッション終了後にできるだけ早く、簡単にアーカイブ動画を配信する

アーキテクチャ

主にAWSとRails、Reactを用いています。

  • AWS
    • AWS Elemental MediaLive:配信チームからの配信データを受け取り、IVSやMediaPackageに流す
    • AWS Elemental MediaPackage:MediaLiveから受け取った配信データを一定期間保持し、アーカイブ動画の作成を行う
    • Amazon IVS:MediaLiveから受け取ったデータをカンファレンス参加者向けに配信する
    • S3:事前録画セッション動画、アーカイブ動画を置く
    • CloudFront:S3に置いた動画を配信する
  • Dreamkast (Rails)
    • MediaLive、MediaPackage、IVSリソースを作成する
    • MediaPackage HarvestJobを作成する
  • Dreamkast-UI (React)
    • 配信視聴ページを提供する

ライブセッションをできるだけ低遅延で配信する

ライブセッションはMediaLiveとIVSを使用して配信します。OBSでMediaLiveに対してRTMP Pushすると、MediaLiveは受け取ったデータをIVSに流し、イベント参加者はIVSの配信データをDreamkast-UI経由で視聴します。

事前録画セッションを配信する

ライブセッションとは別に、事前に録画した動画を提出してもらい、それを流す形式の事前録画セッションもあります。事前録画セッションについてはあらかじめS3上に動画データを置き、CloudFront経由で配信します。イベント参加者はライブセッションと同様、Dreamkast-UI経由で視聴します。

ライブセッション終了後にできるだけ早く、簡単にアーカイブ動画を配信する

o11y2022に限らず、CloudNativeDaysではライブセッションをできるだけ早くアーカイブ動画として公開し、後からイベント参加者が視聴できることを目指しています。o11y2022ではアーカイブの作成にMediaPackageを利用しました。

MediaPackageは受け取った配信データを指定した期間保持し、タイムシフト再生を行うことのできるサービスです。また、MediaPackage HarvestJobというリソースを作ることで、保持された配信データからVODクリップを作成できます。これらの機能を活用することで、セッション終了後数十分程度でアーカイブ動画の公開を実現しました。

MediaPackageはMediaLiveから配信データを受け取っています。MediaLiveを使うことでIVSとMediaPackageに同じソースから配信データを送ることができ、便利です。

MediaPackage HarvestJobを作る際にはアーカイブ動画の開始時刻と終了時刻を指定する必要があります。これらの時刻をどう決めるのかがHarvestJobを使う時の難しさの一つです。Dreamkastでは、切り出し開始時刻と切り出し終了時刻を指定するとプレビューする機能を実装し、微調整できるようにしました。

タイムテーブル上はセッションの開始・終了時刻は決まっているのでその時刻を使ってHarvestJobを自動的に作れそうですが、実際には開始も終了も変わる可能性があります。なので、セッションの開始時刻と終了時刻を切り出し開始時刻と切り出し終了時刻の初期値とするだけにとどめておき、後は人が微調整するという方式としています。

HarvestJobのステータスはDreamkastの管理画面から確認できます。また、定期的にHarvestJobのステータスをポーリングし、ジョブが成功していた場合は切り出されたアーカイブ動画を視聴するためのURLをDreamkastのDBに登録し、イベント参加者がアーカイブ視聴できるようになっています。

過去のイベントでもアーカイブ動画の作成機能はあったのですが、オペレーターの職人芸的作業が必要でした。一発勝負でミスができない方法で非常にオペレーターに負担を強いることになっていました。o11y2022のMediaPackageを使った方法だと、事前にプレビューすることでミスしにくくなっていること、後でミスに気づいても再度HarvestJobを作ることでアーカイブ動画を作り直せることからオペレーターへの負担は小さくなっています。

しかし、実際にオペレーションしてもらった結果、切り出し開始時刻と切り出し終了時刻の微調整が意外と大変ということもわかりました。アーカイブ動画作成に限らず、オペレーションの省力化は今後も取り組みたいと考えています。

本投稿で紹介した仕組みを作っているDreamkastチームでは、開発に興味のある方を探しています。利用技術はRails・React・AWS各種サービスです。現在は基本的にRails上で開発していますが、別サービスに切り出せそうな機能についてはRuby以外の言語を使うことも可能です。関心がある方はご連絡ください。

csvファイルをMarkdownのテーブルに変換するためのコマンド csv2tbl

最近いろんな場所でクエリを実行して結果をesaに貼るために整形する作業が多い。csv形式のテキストデータをGoogle SpreadSheetに貼り付けてコピーするとMarkdown形式のテーブルに変換できるのだけど、それも面倒なのでファイルを渡すと変換するだけのCLIツールを作った。

https://github.com/takaishi/csv2tbl

使い方は説明するほどでもなく、引数にファイルを渡すだけ。

$ cat ./sample.csv
column1,column2,column3
1,2,3
foo,bar,baz

$ ./csv2tbl ./sample.csv
| column1 | column2 | column3 |
|---------|---------|---------|
|       1 |       2 |       3 |
| foo     | bar     | baz     |

こういうほんとにちょっとしたものもツールにしていくのを心がけたい。

gormでmany-to-manyを扱う時、リレーション先のテーブルの内容で絞り込みをする

gormで以下のようなmany-to-manyの関係を扱う時、Tagの内容で絞り込む方法がよくわからず苦戦したので記録しておく。

type Post struct {
	gorm.Model
	Title string `gorm:"column:title"`
	Tags  []Tag  `gorm:"many2many:post_tags"`
}

type Tag struct {
	gorm.Model
	Name  string `gorm:"column:name"`
	Posts []Post `gorm:"many2many:post_tags"`
}

まずは全レコード取得する場合だが、例えばこう書くと思う。

db.Debug().Find(&posts)

これを実行すると以下のようにdeleted_atがNULLのレコードを全て取得する。

[1.677ms] [rows:2] SELECT * FROM `posts` WHERE `posts`.`deleted_at` IS NULL
{Model:{ID:1 CreatedAt:2022-01-05 03:49:24.753 +0000 UTC UpdatedAt:2022-01-05 03:49:24.757 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Title:article-1 Tags:[]}
{Model:{ID:2 CreatedAt:2022-01-05 03:49:24.765 +0000 UTC UpdatedAt:2022-01-05 03:49:24.769 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Title:article-2 Tags:[]}

次に、リレーションがあるテーブルの情報も構造体にマッピングする場合だが、Eager Loadingを使ってこう書く。

db.Debug().Preload("Tags").Find(&posts)

これを実行すると、以下のようにTagsにデータが入っていることがわかる。

2022/01/05 14:10:55 /Users/ryotakaishi/src/github.com/takaishi/gorm_practice/main.go:151
[2.292ms] [rows:2] SELECT * FROM `post_tags` WHERE `post_tags`.`post_id` IN (1,2)

2022/01/05 14:10:55 /Users/ryotakaishi/src/github.com/takaishi/gorm_practice/main.go:151
[2.327ms] [rows:2] SELECT * FROM `tags` WHERE `tags`.`id` IN (1,2) AND `tags`.`deleted_at` IS NULL

2022/01/05 14:10:55 /Users/ryotakaishi/src/github.com/takaishi/gorm_practice/main.go:151
[6.615ms] [rows:2] SELECT * FROM `posts` WHERE `posts`.`deleted_at` IS NULL
{Model:{ID:1 CreatedAt:2022-01-05 03:49:24.753 +0000 UTC UpdatedAt:2022-01-05 03:49:24.757 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Title:article-1 Tags:[{Model:{ID:1 CreatedAt:2022-01-05 03:49:24.743 +0000 UTC UpdatedAt:2022-01-05 03:49:24.743 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Name:tag-1 Posts:[]}]}
{Model:{ID:2 CreatedAt:2022-01-05 03:49:24.765 +0000 UTC UpdatedAt:2022-01-05 03:49:24.769 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Title:article-2 Tags:[{Model:{ID:2 CreatedAt:2022-01-05 03:49:24.748 +0000 UTC UpdatedAt:2022-01-05 03:49:24.748 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Name:tag-2 Posts:[]}]}

さて、問題はここからで、Tagsの中身で絞り込む場合。例えば、tag-2 のタグと関連のあるPostだけほしいような場合。いろいろ調べた結果、以下のようにPreloadとJOINを組み合わせることで実現できた。

db.Debug().
	Preload("Tags").
	Joins("JOIN post_tags ON post_tags.post_id = posts.id").
	Joins("JOIN tags ON post_tags.tag_id = tags.id").
	Where("tags.name in (?)", []string{"tag-2"}).
	Find(&posts)

実行すると以下のようにクエリが発行される。

2022/01/05 14:14:43 /Users/ryotakaishi/src/github.com/takaishi/gorm_practice/main.go:184
[1.978ms] [rows:1] SELECT * FROM `post_tags` WHERE `post_tags`.`post_id` = 2

2022/01/05 14:14:43 /Users/ryotakaishi/src/github.com/takaishi/gorm_practice/main.go:184
[1.952ms] [rows:1] SELECT * FROM `tags` WHERE `tags`.`id` = 2 AND `tags`.`deleted_at` IS NULL

2022/01/05 14:14:43 /Users/ryotakaishi/src/github.com/takaishi/gorm_practice/main.go:184
[7.030ms] [rows:1] SELECT `posts`.`id`,`posts`.`created_at`,`posts`.`updated_at`,`posts`.`deleted_at`,`posts`.`title` FROM `posts` INNER JOIN post_tags ON post_tags.post_id = posts.id INNER JOIN tags ON post_tags.tag_id = tags.id WHERE tags.name in ('tag-2') AND `posts`.`deleted_at` IS NULL
{Model:{ID:2 CreatedAt:2022-01-05 03:49:24.765 +0000 UTC UpdatedAt:2022-01-05 03:49:24.769 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Title:article-2 Tags:[{Model:{ID:2 CreatedAt:2022-01-05 03:49:24.748 +0000 UTC UpdatedAt:2022-01-05 03:49:24.748 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Name:tag-2 Posts:[]}]}

2022-01-07 追記:tags.nameで複数指定した場合、SQL的には複数のレコードが結果として返るが、素朴に構造体にマッピングされてしまい重複することに後で気づいた。

db.Debug().
	Preload("Tags").
	Joins("JOIN post_tags ON post_tags.post_id = posts.id").
	Joins("JOIN tags ON post_tags.tag_id = tags.id").
	Where("tags.name in (?)", []string{"tag-1", "tag-2"}).
	Find(&posts)

これを実行すると以下のようになり、ID:5の構造体がスライス内に2つあることがわかる。これだとGoからは利用しにくい。


2022/01/07 14:57:25 /Users/ryotakaishi/src/github.com/takaishi/gorm_practice/main.go:190
[2.606ms] [rows:4] SELECT * FROM `post_tags` WHERE `post_tags`.`post_id` IN (3,5,4)

2022/01/07 14:57:25 /Users/ryotakaishi/src/github.com/takaishi/gorm_practice/main.go:190
[2.348ms] [rows:2] SELECT * FROM `tags` WHERE `tags`.`id` IN (3,4) AND `tags`.`deleted_at` IS NULL

2022/01/07 14:57:25 /Users/ryotakaishi/src/github.com/takaishi/gorm_practice/main.go:190
[8.796ms] [rows:4] SELECT `posts`.`id`,`posts`.`created_at`,`posts`.`updated_at`,`posts`.`deleted_at`,`posts`.`title` FROM `posts` JOIN post_tags ON post_tags.post_id = posts.id JOIN tags ON post_tags.tag_id = tags.id WHERE tags.name in ('tag-1','tag-2') AND `posts`.`deleted_at` IS NULL
{Model:{ID:3 CreatedAt:2022-01-07 01:19:28.754 +0000 UTC UpdatedAt:2022-01-07 01:19:28.758 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Title:article-1 Tags:[{Model:{ID:3 CreatedAt:2022-01-07 01:19:28.744 +0000 UTC UpdatedAt:2022-01-07 01:19:28.744 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Name:tag-1 Posts:[]}]}
{Model:{ID:5 CreatedAt:2022-01-07 01:19:28.777 +0000 UTC UpdatedAt:2022-01-07 01:19:28.782 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Title:article-3 Tags:[{Model:{ID:3 CreatedAt:2022-01-07 01:19:28.744 +0000 UTC UpdatedAt:2022-01-07 01:19:28.744 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Name:tag-1 Posts:[]} {Model:{ID:4 CreatedAt:2022-01-07 01:19:28.749 +0000 UTC UpdatedAt:2022-01-07 01:19:28.749 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Name:tag-2 Posts:[]}]}
{Model:{ID:4 CreatedAt:2022-01-07 01:19:28.766 +0000 UTC UpdatedAt:2022-01-07 01:19:28.77 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Title:article-2 Tags:[{Model:{ID:4 CreatedAt:2022-01-07 01:19:28.749 +0000 UTC UpdatedAt:2022-01-07 01:19:28.749 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Name:tag-2 Posts:[]}]}
{Model:{ID:5 CreatedAt:2022-01-07 01:19:28.777 +0000 UTC UpdatedAt:2022-01-07 01:19:28.782 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Title:article-3 Tags:[{Model:{ID:3 CreatedAt:2022-01-07 01:19:28.744 +0000 UTC UpdatedAt:2022-01-07 01:19:28.744 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Name:tag-1 Posts:[]} {Model:{ID:4 CreatedAt:2022-01-07 01:19:28.749 +0000 UTC UpdatedAt:2022-01-07 01:19:28.749 +0000 UTC DeletedAt:{Time:0001-01-01 00:00:00 +0000 UTC Valid:false}} Name:tag-2 Posts:[]}]}

gormの機能でどうにかできないか軽く調べたが見つからなかった。とりあえず、ワークアラウンドとしては自分で重複排除する方法がある。

m := map[uint]bool{}
uniq := []Post{}
for _, post := range posts {
	if !m[post.ID] {
		m[post.ID] = true
		uniq = append(uniq, post)
	}
}

このようにすればよい。