nykergoto’s blog

機械学習とpythonをメインに

ECSのHealthCheckがDjangoの動的ページのときに失敗する

Django を ECS with Fargate に Deploy しているようなアプリケーションがあり、Django の ECS task には ALB が紐付いていている状況を考える。

TargetGroup はつながっている先のサーバーが動作しているかどうかをテストして Healthy or Not を判断するような仕組みで、はじめはこの確認先を静的なファイル /health_check.html で運用しようと思っていたのだけれど、ふとこれを動的に生成するページに変更するとステータスコード 400 が帰ってくるため health check に失敗するようになった。

ちなみになんで動的なページにしたいかというと静的ファイルだと nginx で止まってしまうので Django の処理まで到達させたい、という気持ちがあったためである。具体的には nginx の config には以下のような設定が入っているので, Django の static file を指定すると $uri で match して Django server までリクエストが到達しないので、なんか嫌だなーという気持ち。nginx だけ動いていて Django が動かないことってまあないとは思うのだけれど、動きとしては確認したいじゃない?

server {
  # 中略
  # django の public (collect static のファイルが保存されるディレクトリ) が root に指定されている
  root   /var/www/django-app/public;

  location / {
    # なので $uri で static file に match するため `@proxy_to_app` にたどり着かない
    try_files $uri $uri/ $uri/index.htm @proxy_to_app;
  }

  location @proxy_to_app {
    # ここまで来れると, django につながる (正確には gunicorn server)
    proxy_pass http://app_server;
  }
}

health check の該当の path にブラウザから直接アクセスすると問題なく画面は表示されていて、ステータスコードは 200。ちなみに health check 先を静的ファイルに変更すると 200 が帰るようになるので、単純に静的・動的によって TargetGroup からのリクエストが成功する失敗するが切り替わることがわかった。

今の ECS Container で動く Django サーバーの構成は nginx + gunicorn で起動していて、上に書いたように静的ファイルは nginx 側で処理されるようになっている。したがって

  • nginx からのレスポンス = 200
  • nginx → gunicorn 経由の django のレスポンス = 400

ということ。

色々調べていると Allow Domains の設定の影響であるという stack overflow にたどり着いた。

stackoverflow.com

要約すると

  • Target Group から送られる health check のリクエストは、ECS のコンテナが存在している IP を直で叩きに行く。
  • Django がそのリクエストを受け取ると IP は Allowed Host にないためエラー (400) を返す。

ということらしい。解決方法はいくつか提示されていて、一つは Django が起動したときに今動いている ECS Container のメタ情報を AWS へリクエストして Allowed Host を増やすというもの。なかなか斬新なアイディアだが、今実行している container の状況については、AWS の公式が提供している機能としてあるらしい。初耳。

python の package もあって, 実装はこういう感じ. app が読み込まれるタイミングで meta data から取得できる local-ipv4 を allowed hosts 追加するという動きになっている.

https://github.com/sjkingo/django-ebhealthcheck/blob/master/ebhealthcheck/apps.py

from django.apps import AppConfig

class EBHealthCheckConfig(AppConfig):
    name = 'ebhealthcheck'

    def ready(self):
        """
        Fix for the EB health check host header:

        https://aalvarez.me/posts/setting-up-elastic-beanstalk-health-checks-with-a-django-application/
        https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html
        """

        from django.conf import settings
        import requests

        def get_ec2_instance_ip():
            metadata_url = "http://169.254.169.254/latest"
            n = 0
            while n < 10:
                try:
                    token = requests.put(metadata_url + "/api/token", timeout=0.1, headers={
                        "X-aws-ec2-metadata-token-ttl-seconds": "60",
                    }).text
                    ip = requests.get(metadata_url + "/meta-data/local-ipv4", timeout=0.1, headers={
                        "X-aws-ec2-metadata-token": token,
                    }).text
                except (requests.exceptions.ConnectionError, requests.exceptions.InvalidHeader):
                    n += 1
                    continue
                else:
                    return ip
            return None

        public_ip = get_ec2_instance_ip()
        if public_ip:
            settings.ALLOWED_HOSTS.append(public_ip)

これを入れて問題なく動くかなーと思ったら, health check は一向に直らない。

request に失敗しているのかも? とおもい ECS execute command を使って実行中の container 内部で http://169.254.169.254/latest にアクセスすると 400 エラーになった。ということはこの metadata 取得方法ではうまく行かないということのよう。よくわからないが、多分別のエンドポイントにアクセスしないと行けないんだろうなと思いつつ調べていると Fargate などの仮想マシン上では別の取得方法があるらしいことがわかった。

Use appropriate ECS credentials on CodeBuild maven job: https://stackoverflow.com/questions/42794486/use-appropriate-ecs-credentials-on-codebuild-maven-job/47028691#47028691

これによると

When using AWS Containers (Like CodeBuild does). The instance metadata is at a different location to the usual http://169.254.169.254/latest/meta-data/

Instead. AWS sets an Environment variable $AWS_CONTAINER_CREDENTIALS_RELATIVE_URI which points to the correct URI to obtain metadata. This is required by the AWS SDK's and other tools in order to assume an IAM Role.

とのこと。なるほどこれは CodeBuild だけど仮想マシンという意味では似ているしこの筋でいけるかも? と思ってとりあえず書いてあるエンドポイント http://169.254.170.2$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI に container からリクエストするとデータが取れた! 結論から言うと Fargate では https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/task-metadata-endpoint-v3.html このドキュメントにあるようなエンドポイント経由でメタデータを取るのが正解らしい。具体的には以下の ECS_CONTAINER_METADATA_URI に入っている URL を利用するのが正解。

${ECS_CONTAINER_METADATA_URI}
このパスはコンテナのメタデータ JSON を返します。

レスポンスは typescript のスキーマとかがあるわけではないのでレスポンスを見て類推。

{
  "DockerId": "43481a6ce4842eec8fe72fc28500c6b52edcc0917f105b83379f88cac1ff3946",
  "Name": "nginx-curl",
  "DockerName": "ecs-nginx-5-nginx-curl-ccccb9f49db0dfe0d901",
  "Image": "nrdlngr/nginx-curl",
  "ImageID": "sha256:2e00ae64383cfc865ba0a2ba37f61b50a120d2d9378559dcd458dc0de47bc165",
  "Labels": {
    "com.amazonaws.ecs.cluster": "default",
    "com.amazonaws.ecs.container-name": "nginx-curl",
    "com.amazonaws.ecs.task-arn": "arn:aws:ecs:us-east-2:012345678910:task/9781c248-0edd-4cdb-9a93-f63cb662a5d3",
    "com.amazonaws.ecs.task-definition-family": "nginx",
    "com.amazonaws.ecs.task-definition-version": "5"
  },
  "DesiredStatus": "RUNNING",
  "KnownStatus": "RUNNING",
  "Limits": {
    "CPU": 512,
    "Memory": 512
  },
  "CreatedAt": "2018-02-01T20:55:10.554941919Z",
  "StartedAt": "2018-02-01T20:55:11.064236631Z",
  "Type": "NORMAL",
  "Networks": [
    {
      "NetworkMode": "awsvpc",
      "IPv4Addresses": ["10.0.2.106"]
    }
  ]
}

見た感じ NetworksIPv4Addresses の配列を全部入れれば OK っぽい。ということで実装。

# `elb_healthcheck/apps.py

import os
from typing import List

import requests
from django.apps import AppConfig


class ESCHealthcheckConfig(AppConfig):
    """Fargate上で動く instance の local ip を allowed hosts に追加する"""

    name = "ecs_healthcheck"

    def ready(self):
        from django.conf import settings

        current_allowed_hosts = [*settings.ALLOWED_HOSTS]
        print("current allowed hosts: {}".format(", ".join(current_allowed_hosts)))

        url = os.getenv("ECS_CONTAINER_METADATA_URI", None)

        if url is None:
            print("uri is not found")
            return

        res = requests.get(url, timeout=1)
        if res.status_code != 200:
            print("fail to fetch")
            return
        data = res.json()
        local_ips: List[str] = data.get("Networks", {}).get("IPv4Addresses", "")

        if len(local_ips) > 0:
            print(f"add local ip: {', '.join(local_ips)}")
            settings.ALLOWED_HOSTS = [*current_allowed_hosts, *local_ips]

この app を INSTALL_APPS に追加すれば OK

# settings.py

INSTALLED_APPS = [
    "ecs_healthcheck",
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    ...
]

余談

これで一見落着、と思ったが Health Check は最低限のリクエストとするべき、という記事を見つけてしまった。

https://cha-shu00.hatenablog.com/entry/2022/08/20/144747
ELB によるヘルスチェックでは、「アプリケーションが新規のリクエストを受付できること」だけを検証するべき。例えば、"ok"などの単純な文字列を返したり、小さな静的コンテンツを返すようなパスを指定する。

確かに DB への接続を watch したければサービスの正常な動作を見るような仕組みを入れたほうがいいよね。例えば betteruptime とか。ただ nginx の動きが OK ならアプリケーションが新規のリクエストを受け取れる状態なのか、は僕にはなんともわからなかった。気持ち的には Django まで動いているぞ! がわかったほうが気分はいいけどそれはやりすぎかもなー。宿題です。