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 ファイルを読もうとしてエラーになることがある、という話でした。