i-Vinci TechBlog
株式会社i-Vinciの技術ブログ

Fargate+Dockerで名前解決リクエストに挑んだ話

皆さんこんにちは。i-Vinci 下位です。

モンスターハンターワイルズに夢中で、またもや睡眠時間が脅かされております。
数年前もサンブレイクで似たような目にあった気がしますが、多分気の所為です。

さて、今回はFargate+Dockerで名前解決リクエストに挑んだ話をご紹介します。

やること

端的にいってしまうと、Fargateから外部サービスにHTTPリクエストをしますという内容です。

勿論、HTTPクライアントを作って、ただ外部にリクエストをする、だけではないのでご安心ください。
そんな記事では刺激が少なすぎるので、ちゃんと捻りを加えます

では改めて、今回のシナリオは以下の通りです。

シナリオ

  1. 実行環境はFargate + アプリ(Docker(Java))
  2. アプリで実行することは、外部ネットワークに属するサービスへのHTTPリクエスト
  3. DNS提供はされないが、外部サービスへのリクエストはホスト名を用いてリクエストしなければならない

ネットワーク関連図はこのイメージ。

ネットワーク図

シナリオが限定的すぎてイメージが沸かない、との声が聞こえそうですね。
しかし、以下の要望が追加されたら、事情が変わってきませんか。

  1. Route53の増設は、情報の二重管理、コスト増の観点からやりたくない。
  2. リクエスト先の外部ネットワークはセキュリティの観点からIPアドレス指定での通信を遮断している

つまり、DNSサーバーは提供できないけど、ホスト名でリクエストしないと通信がそもそも届かないよ!ということです。そんなバカな

検証モジュール

それでは、今回の検証に用いるモジュールです。
aws-samを使って、lambda + javaのテンプレートを持ってきました。

sam build && sam local invokeで、動作確認をしていきます。

今回の名前解決検証はlambdaで十分確認可能なので、この方式で進めます。
別にecs + Fargateの環境構築が面倒だから横着したとかそういう理由ではないですよ?

App.java

public class App implements RequestHandler<APIGatewayProxyRequestEvent, APIGatewayProxyResponseEvent> {
    public APIGatewayProxyResponseEvent handleRequest(final APIGatewayProxyRequestEvent input, final Context context) {
        APIGatewayProxyResponseEvent response = new APIGatewayProxyResponseEvent();

        try {
            var request = HttpRequest.newBuilder(URI.create("https://hogehoge-example.com")).GET().build();
            var client = HttpClient.newBuilder().build();
            var responseResult = client.send(request, HttpResponse.BodyHandlers.ofString());

            return response
                    .withStatusCode(responseResult.statusCode())
                    .withBody(responseResult.body());
        } catch (IOException e) {
            return response
                    .withBody("{ error: io-error }")
                    .withStatusCode(500);
        } catch (InterruptedException e) {
            return response
                    .withBody("{ error: interrupted-error }")
                    .withStatusCode(500);
        }
    }
}

Dockerfile

FROM public.ecr.aws/sam/build-java21:latest as build-image

ARG SCRATCH_DIR=/var/task/build

COPY src/ src/
COPY gradle/ gradle/
COPY build.gradle gradlew ./

RUN mkdir build
COPY gradle/lambda-build-init.gradle ./build

RUN ./gradlew --project-cache-dir $SCRATCH_DIR/gradle-cache -Dsoftware.amazon.aws.lambdabuilders.scratch-dir=$SCRATCH_DIR --init-script $SCRATCH_DIR/lambda-build-init.gradle build
RUN rm -r $SCRATCH_DIR/gradle-cache
RUN rm -r $SCRATCH_DIR/lambda-build-init.gradle
RUN cp -r $SCRATCH_DIR/*/build/distributions/lambda-build/* .

FROM public.ecr.aws/lambda/java:21

COPY --from=build-image /var/task/META-INF ./
COPY --from=build-image /var/task/helloworld ./helloworld
COPY --from=build-image /var/task/lib/ ./lib

CMD ["helloworld.App::handleRequest"]

実行結果

java.nio.channels.UnresolvedAddressExceptionという例外が送出されますね。
これ、名前解決に失敗したことを示しています。

今回はこの例外の解消を目指します。

START RequestId: ec65e98f-5dcf-489a-962b-dd209283f996 Version: $LATEST
java.net.ConnectException
        at java.net.http/jdk.internal.net.http.HttpClientImpl.send(Unknown Source)
        ~~
Caused by: java.net.ConnectException
        at java.net.http/jdk.internal.net.http.common.Utils.toConnectException(Unknown Source)
        ~~
Caused by: java.nio.channels.UnresolvedAddressException
        at java.base/sun.nio.ch.Net.checkAddress(Unknown Source)
        at java.base/sun.nio.ch.Net.checkAddress(Unknown Source)
        at java.base/sun.nio.ch.SocketChannelImpl.checkRemote(Unknown Source)
        at java.base/sun.nio.ch.SocketChannelImpl.connect(Unknown Source)
        at java.net.http/jdk.internal.net.http.PlainHttpConnection.lambda$connectAsync$1(Unknown Source)
        at java.base/java.security.AccessController.doPrivileged(Unknown Source)
        ... 33 more
END RequestId: b8a2fb62-6833-4d44-aaa1-02c42b26bb6a
REPORT RequestId: b8a2fb62-6833-4d44-aaa1-02c42b26bb6a  Init Duration: 0.06 ms  Duration: 1180.22 ms    Billed Duration: 1181 ms        Memory Size: 512 MB     Max Memory Used: 512 MB
{"statusCode": 500, "body": "{ error: io-error }"}

解決手段について

解決手段について考えます。
が、その前にそもそも名前解決の仕組みについて考えましょう。

名前解決とは

雑な名前解決イメージ

図のように、名前解決とは以下の順序で情報を参照する様になっています。

  1. hostsを見る
  2. DNSサーバーを見る

hostsが先に立ってますよね。
そうです、hostsに定義があれば実はDNSサーバー無しでも名前解決が可能なんですね。

hostsとはなんぞや、となった方はこちらをご参照ください。
恐れを知らない要約をしますと、端末内でのみ有効な名前解決の定義ができるファイルを指します。

皆さん、localhostって使いますよね。あれ、hosts管理です。

今回の解決手段

ということで、hostsを編集してしまえば本事象は解決となります。何だ簡単じゃないかとかいわないで

ではhostsを編集したいところなのですが、docker環境のhostsの編集方法、皆さんはご存じでしょうか?

Fargate + Dockerでもhosts編集がしたい

お待たせしました。本編です。

ただhosts編集するだけなら簡単です。しかしながら今回のターゲットはFargate + Dockerです。
FargateにはECR(Elastic Container Registry)イメージを渡さなければいけません。つまり、Dockerイメージを渡さなければいけないということですね。

ということで、dockerでhostsを触る方法を探りましょう。

Case1. Docker公式の力を使う(add-host)

docker + hosts変更といえば、add-hostオプションです。
このオプションを用いれば楽にhostsファイルの編集が可能となります。

尚、Fargateの場合は利用できません。

ECS + Fargate構成でdockerイメージを公開する場合、Fargateに渡せるのはECRのリポジトリURLまでです。
つまり、dockerイメージまでしか渡すことができません。
※詳細はタスク定義、taskdefinitionあたりで調べてみてください。

そもそもdocker起動プロセスに干渉することができない為、この手法は使えないということですね。次いきましょう。


Case2. AWSサービスの力を使う(ECS タスク定義パラメータ)

Fargateとセットで用いるECS(タスク定義)にはhosts編集を行うことができる、extraHostsというパラメータが存在します。
このパラメータを使えば解決できそうですね。

以下、説明文の抜粋です。

このパラメータは、awsvpc ネットワークモードを使用するタスクではサポートされていません。
タイプ: オブジェクト配列
必須: いいえ
コンテナ上の /etc/hosts ファイルに追加する、ホスト名と IP アドレスのマッピングリスト。
このパラメータは docker create-container コマンドの ExtraHosts にマッピングされ、--add-host オプションは docker run にマッピングされます。

因みにFargateを使う場合はawsvpc ネットワークモード固定となります。はい次。


Case3. イメージ生成時にhostsを書き換える(docker-build)

公式手段では対処ができない、ということが分かったのでダーティにいきます。
具体的には、Dockerfile内でhostsを書き換えてしまいます。

以下の宣言をDockerfileに一筆加えてbuildです。

RUN echo '127.0.0.1 hogehoge-example.com' >> /etc/hosts && cat /etc/hosts

ビルドログ

Step 16/17 : RUN echo '127.0.0.1 hogehoge-example.com' >> /etc/hosts && cat /etc/hosts
 ---> Running in c3a1da95b919
127.0.0.1       localhost
::1     localhost ip6-localhost ip6-loopback
fe00::  ip6-localnet
ff00::  ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.17.0.2      c3a1da95b919
127.0.0.1 hogehoge-example.com
 ---> Removed intermediate container c3a1da95b919
 ---> 3f1f07f647c4
Step 17/17 : CMD ["helloworld.App::handleRequest"]
 ---> Running in ddcd0bc02884
 ---> Removed intermediate container ddcd0bc02884
 ---> 66294e2ce834
Successfully built 66294e2ce834
Successfully tagged helloworldfunction:java21-gradle-v1

ビルドログからもわかる通り、hostsへの定義追加が為されていますね。では実行ログです。

START RequestId: c90ea1dd-63c2-4678-aa93-54f0f59a47f3 Version: $LATEST
java.net.ConnectException
        at java.net.http/jdk.internal.net.http.HttpClientImpl.send(Unknown Source)
        ~~
Caused by: java.net.ConnectException
        at java.net.http/jdk.internal.net.http.common.Utils.toConnectException(Unknown Source)
        ~~
Caused by: java.nio.channels.UnresolvedAddressException
        at java.base/sun.nio.ch.Net.checkAddress(Unknown Source)
        at java.base/sun.nio.ch.Net.checkAddress(Unknown Source)
        at java.base/sun.nio.ch.SocketChannelImpl.checkRemote(Unknown Source)
        at java.base/sun.nio.ch.SocketChannelImpl.connect(Unknown Source)
        at java.net.http/jdk.internal.net.http.PlainHttpConnection.lambda$connectAsync$1(Unknown Source)
        at java.base/java.security.AccessController.doPrivileged(Unknown Source)
        ... 33 more
END RequestId: e14e4fe0-3ffb-435a-aab5-483e3a97bfd1
REPORT RequestId: e14e4fe0-3ffb-435a-aab5-483e3a97bfd1  Init Duration: 0.50 ms  Duration: 816.92 ms     Billed Duration: 817 ms Memory Size: 512 MB     Max Memory Used: 512 MB
{"statusCode": 500, "body": "{ error: io-error }"}

はい。これでは解消しません。

これはdocker自体の仕組みなのですが、コンテナのhostsファイルはコンテナ起動のタイミングで決定されます。
従って、build時にいくらhostsファイルを変更したとしても、コンテナ起動のタイミングで全て失われてしまうという訳です。

この事象を回避する為には、add-hostオプションを使って、明示的にコンテナにhost追加を指示する必要があります。
まぁ、この手法はCase1で頓挫したのですが。。。

ということで、次です。


Case4. コンテナ開始時にhostsを書き換える(docker-run)

最後の手段です。buildでダメならrunで直せばいいじゃないの方針です。

dockerコンテナは、Dockerfileのcmd, entrypointで指定したプロセスが終了するとコンテナも終了される特性を持ちます。
つまり、entrypointが存在する筈なので、そやつを探して、乗っ取ってしまえばコンテナ起動直後のコントロールをとれそうです。

ということで、公開イメージであるpublic.ecr.aws/lambda/java:21の定義を覗きましょう。

末端にentrypointの定義が確認できると思います。
/lambda-entrypoint.sh、このshellを編集すれば起動直後の制御が可能となりそうですね。

ENTRYPOINT [ "/lambda-entrypoint.sh" ]

では、解決編です。

1. lambda-entrypoint.shの上書き

Dockerfile内で上書きしましょう。編集を加えた同名ファイルをCOPYすればOKです。

Dockerfile

ファイル終端、CMDの手前あたりに記載します。

COPY lambda-entrypoint.sh /lambda-entrypoint.sh
lambda-entrypoint.sh

lambda-entrypoint.shのオリジナルはdocker-imageから取得します。
取得したshellのあたりさわりのなさそうなところに、hostsの編集処理を追加です。

#!/bin/sh
# Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.

# 追加したい定義をhostsに追記
echo "127.0.0.1 hogehoge-example.com" >> /etc/hosts

if [ $# -ne 1 ]; then
  echo "entrypoint requires the handler name to be the first argument" 1>&2
  exit 142
fi
export _HANDLER="$1"

RUNTIME_ENTRYPOINT=/var/runtime/bootstrap
if [ -z "${AWS_LAMBDA_RUNTIME_API}" ]; then
  exec /usr/local/bin/aws-lambda-rie $RUNTIME_ENTRYPOINT
else
  exec $RUNTIME_ENTRYPOINT
fi

2. 再実行

以下、実行ログです。

START RequestId: e585c713-2823-43cc-8086-1ee3e2407077 Version: $LATEST
java.net.ConnectException
        at java.net.http/jdk.internal.net.http.HttpClientImpl.send(Unknown Source)
        ~~
Caused by: java.net.ConnectException
        at java.net.http/jdk.internal.net.http.common.Utils.toConnectException(Unknown Source)
        ~~
Caused by: java.nio.channels.ClosedChannelException
        at java.base/sun.nio.ch.SocketChannelImpl.ensureOpen(Unknown Source)
        at java.base/sun.nio.ch.SocketChannelImpl.beginConnect(Unknown Source)
        at java.base/sun.nio.ch.SocketChannelImpl.connect(Unknown Source)
        at java.net.http/jdk.internal.net.http.PlainHttpConnection.lambda$connectAsync$1(Unknown Source)
        at java.base/java.security.AccessController.doPrivileged(Unknown Source)
        ... 10 more
END RequestId: e76acb08-4495-47f8-a4cc-b5e7ba2e19aa
REPORT RequestId: e76acb08-4495-47f8-a4cc-b5e7ba2e19aa  Init Duration: 0.06 ms  Duration: 871.58 ms     Billed Duration: 872 ms Memory Size: 512 MB     Max Memory Used: 512 MB
{"statusCode": 500, "body": "{ error: io-error }"}

いかがでしょう。java.nio.channels.UnresolvedAddressExceptionが消えました。
件の例外が消えたということは、hostsによる名前解決に成功したということです。これで一安心ですね。

まとめ

Fargate+DockerでDNSサーバーに頼らない名前解決についてお送りしました。

AWSでも言語(java)でもなく、docker側で力技解決という内容ではありましたが、意外と刺さる場面はあるかもしれません。
困った時の変化球として、是非皆さんの手札に加えておいて頂ければと思います!

使える場面とか需要とか考えると心底疑問には感じますが、手札が多いに越したことはないですからね。