初めてのAWS ECS (本編)

AWS ECS (Elastic Container Service)を使って、クローラを作ってみる記事。前編の記事でDocker環境内でPythonにてクローラを完成させるところまで作った。本記事ではいよいよ本編のECSを使ってそれを動かしてみる

初めてのAWS ECS (準備編)

S3の準備

本編と言いつつクローラのアウトプットをAWS S3とするためその準備を行う。

「hiyuzawa-wadai-crawler」という名称でap-northeast-1にS3バケットを作成した。パブリックアクセス制限等は全てデフォルト(パブリックアクセスを制限)とひとまずしておく。

 % aws s3 ls
2021-04-30 00:05:27 aws-sam-cli-managed-default-samclisourcebucket-pmvqaf5xnjy0
...
2021-05-08 10:22:09 hiyuzawa-wadai-crawler

AWSアプリケーションアカウントを作成

クローラから作成したS3にクロール結果を送信するアプリケーションアカウントを作成し、そのアカウントに上で作ったS3バケットに対してPutの許可を与える。まず先に準備編で作ったクローラに先にS3にPutする処理を追加して一旦、その送信が失敗することを確認してみる。

以下のように準備編で作ったクローラスクリプトにで作ったS3にアップロードする処理(関数)を加えている。 (requirement.txt に boto3 を追加も忘れずに。)

import requests
from bs4 import BeautifulSoup
import json
import boto3
import datetime as dt

TARGET_URL = "https://search.yahoo.co.jp/realtime"


def upload_s3(file):
    s3 = boto3.resource('s3')
    bucket = s3.Bucket('hiyuzawa-wadai-crawler')
    today = dt.date.today()
    file.split(".")
    s3file_name = "{}_{}.{}".format(file.split(".", 1)[0], today.strftime("%Y%m%d"), file.split(".", 1)[1])
    bucket.upload_file(file, s3file_name)


def main():
    keyword = wadai_crawl()
    print(keyword)
    f = open("wadai.json", "w")
    f.write(json.dumps(keyword))
    f.close()
    upload_s3("wadai.json")


def wadai_crawl():
    keywords = []
    res = requests.get(TARGET_URL)
    soup = BeautifulSoup(res.text, 'html.parser')
    for item in soup.select("#contentsBody > div.main > article.Trend_Trend__5OTRp > section > ol:nth-child(1) > li"):
        keywords.append(item.find("h1").getText())
    for item in soup.select("#contentsBody > div.main > article.Trend_Trend__5OTRp > section > ol:nth-child(2) > li"):
        keywords.append(item.find("h1").getText())
    return keywords


if __name__ == "__main__":
    main()

これをDocker環境で実行すると、予定通りS3の送信のところで raise NoCredentialsError() エラーとなる。 もしかするとDocker環境ではなくcrawler.py を単体で動かすと問題なくS3へのPutまで成功するかもしれません。それはおそらく実行シェル環境にてaws cli 等の設定が済でそのユーザにてS3のPutができる権限があり成功できているはずです。(あくまでも最終的にはDocker環境で動作させるためそこでの失敗を確認してください)

% docker build -t wadai-crawler .
% docker run wadai-crawler
['クロコダイン', '24時間テレビ', 'ザオラル', 'ゴーヤー', '洗濯物', '東京1R', 'シールド', 'えび銭湯', '池江璃花子', 'ダイの大冒険', 'サタプラ', 'ウメハラ', '甚だしい', 'シノファーム', 'メンノン', 'ワンオペ育児', '伊東純也', '旅サラダ', '五等分の花嫁 一番くじ', '新潟1R']
Traceback (most recent call last):
  File "/opt/app/crawler.py", line 39, in <module>
    main()
  File "/opt/app/crawler.py", line 24, in main
    upload_s3("wadai.json")
  File "/opt/app/crawler.py", line 16, in upload_s3
    bucket.upload_file(file, s3file_name)
....
....
  File "/usr/local/lib/python3.9/site-packages/botocore/signers.py", line 162, in sign
    auth.add_auth(request)
  File "/usr/local/lib/python3.9/site-packages/botocore/auth.py", line 373, in add_auth
    raise NoCredentialsError()

さて、ようやくAWSのアプリケーションアカウントを作成します。IAMよりユーザの作成にて以下のように wadai-crawlerというアカウントを作成しました。

アクセス権の設定は一旦「AmazonS3FullAccess」を与えます。

アカウントが作成されるとアクセスキー IDシークレットアクセスキーが発行されます。これをDockerの実行環境に適応するためにDockerfileに環境変数を加えます。

FROM python:slim

WORKDIR /opt/app

ENV AWS_ACCESS_KEY_ID [ここに発行されたアクセスIDを貼り付ける]
ENV AWS_SECRET_ACCESS_KEY [ここに発行されたシークレットアクセスキーを貼り付ける]
ENV AWS_DEFAULT_REGION ap-northeast-1
ENV AWS_DEFAULT_OUTPUT json

COPY requirements.txt .
COPY app/ .

RUN pip install --upgrade pip
RUN pip install -r requirements.txt

ENTRYPOINT python crawler.py

これでDocker環境にて実行すると失敗することなくS3にクロール結果が送信されていることが確認できます

 % aws s3 ls s3://hiyuzawa-wadai-crawler
2021-05-08 11:02:12        697 wadai_20210508.json

ECSクラスタの作成

ようやくECSの準備にとりかかる。ECS自体の細かい説明は本記事では行わない。また筆者の理解も若干曖昧なまま記事を記載しているので細かい解釈や理解が間違っている可能性があることを以下ご了承頂きたい。

ECSではDockerの実行環境としてクラスタが必要です。クラスタはアプリケーションが動作するサーバ群環境です。あえて”群”と記載したのは本格的なECS利用ではアプリケーションの冗長性を確保する目的でサーバが複数で構成される状況もあるからです。が、今回は細かいことはスキップしてクラスタを作成する。

AWSコンソールのECSのページを開き、クラスターの作成をクリックする。(もしかしたらチュートリアルのようなものが出るかもしれないが、ここではそれを実行せず標準なフローで行う)

 すると、次の画面でクラスターテンプレートの作成という選択がでてくる。

ここでAWS Fargateという単語が出現する。AWSのクラスタの実際の実行環境はEC2である。そのEC2を自分で管理する(EC2インスタンス一覧に当然現れる)か、よしなにAWSにおまかせする(ECSでタスク実行時にのみAWSが用意したサーバで実行してもらう)の違いだと雑に理解する。

上の説明だけだと、AWS Fargate 一択に聞こえるが、EC2で自前管理の方がメリットがでるケースも当然あるのでその選択肢がある。詳しいことは他の記事を参照してください。今回は AWS Faragate の方を選択する

次の画面ではクラスタ名やネットワーク環境を定義する画面でVPCの作成にチェックを入れただけでほぼデフォルトで作成とした。

Fargateといってもネットワークはちゃんと定義は必要。ここで定義したネットワークはコンソールのVPCでも確認できる

作成が完了するとECSメイン画面にクラスタが表示される。FARGATEとEC2、それぞれに実行中のサービスとタスク数が表示されすべて0となっているはずです。クラスタの作成でFARGATEと選択したのになぜEC2の項目があるのかということは、クラスタの作成で選択したのは単純に初期値としてどうしますか?というだけであり、このクラスタに対してあとからEC2を追加するとEC2実行環境でこのクラスタ内のタスク/サービスを実行することができると理解する。

ECRでDocker Imageを管理する

ECR ( Elastic Container Registry ) は Docker HubのようにAWS内でDockerImageを管理するサービスです。ここに作成したCrowlerのDocker Imageを保存(Push)します。

ECRは ECSの画面の左カラムからも簡単にアクセスできる。遷移先で「リポジトリの作成」をクリックする。一般設定では「プライベート」の選択肢のままリポジトリ名を記載し(wadai-crawlerとした)作成をする

 リポジトリの作成が終わると、リポジトリ一覧に作成したリポジトリ名がエントリされてます。現状は、枠は作りました、という状態なのでここに実際のDockerImageをPushします。Pushの方法はリポジトリ詳細の右上にある「プッシュコマンドの表示」のところに親切に手順が記載されています。

実際には以下の4つのコマンドです。順に行えば特に問題ないと思います。詰まる可能性としては4のPushで権限がないとErrorになることが想定されますが、おそらく、1でログインしたアカウントに権限が無いことが原因です(AmazonEC2ContainerRegistryPowerUser 等の権限が必要)

  1. AWS CLIでログイン
  2. イメージの作成
  3. イメージへタグ付け
  4. ECRにプッシュ

Pushが成功するとECRの画面にイメージがlatestとして登録されるはずです。

タスクの作成

ECSではクラスタ内で動作するアプリケーションは サービスタスク という関係があります。サービスは1つ以上のタスクで構成され、その定義には以下の定義をする必要があります。

サービスで定義する項目
  • いくつのタスクで構成するか
  • その冗長性をどう確保するか(タスクがエラー等で消えたときにどうリカバリするか)
  • サービスを外部に公開(API公開をイメージするとわかりやすい)する方法(ロードバランシング)

サービスを利用するにもまずタスクの登録が必要ですが、今回は単純なクローラであり、クロールが終わったら終了なので、サービスを使わず、タスクを定義し、それを単体で実行する(タスクを直接実行する)方法をとります

では、実際にタスクを作成します。ECS左カラムのタスクの定義から「新しいタスク定義の作成」を選択。それぞれ以下のように選択する

タスクをFARGATE と EC2 のどちらのクラスタで実行するのかを選択 (FARGATEを選択)。詳細設定でタスク定義名(wadai-crawler)とタスクロール、タスク実行ロールは両方 ecsTaskExecutionRoleをひとまず選んどけば良いです。

タスクメモリとタスクCPUは実行時に割り当てるリソース。今回は単なるクローラなので、いずれも最低の設定(0.5GB / 0.25vCPU)を選択します。

重要なのは中段にあるコンテナの定義です。「コンテナを追加」を選択するとたくさんの設定を持つコンテナの追加画面がでます。最低限は赤枠のコンテナ名(任意の文字列(wadai-crawlerとした))とコンテナのイメージの場所(先に登録したECRレポジトリの該当イメージのURIをコピペ、 最後に:latest を付与して指定します)

以上、(それ以外はデフォルト)で作成を完了するとタスク一覧に作成したタスクが表示されるはずです。

タスクを実行してみる

作ったタスクを実行してみます。タスクを実行したいECSクラスタ(default)を選択して、「タスク」タブを選択。「新しいタスクの実行」を選択すると定義済みタスクを単体で実行できます。タスクの実行を選択したあとの画面で、起動タイプ(FARGATEを選択)、タスクの定義は先程作ったタスクを選択。実行するVPCとサブネット(サブネットは2つ標準で作られてるケースはaを選んどけば無難)。パブリックIPの自動割当は「ENABLED」を選択

以上、セットした後、最下部の「タスクの実行」をクリックするとタスクがクラスタ内で実行準備に入ります。

クラスタの画面に戻るといかのように表示されているはずです。

保留中のステータスが1個(先程実行開始したタスク)

タスク一覧に1つ登録されている。

ブラウザを何度かリロードしてステータスが変わるまでまつ。もしかしたらタスク自体が瞬時に終わるので実行中の状態が見れずに、保留中・実行中のタスクが0になるかもしれません。タスクの一覧のステータスから「Stopped」を選択すると直近に終了したタスクが表示されます。タスクの詳細、logs(ログ)を選択すると実行が終了したことがわかります。

Logで確認してエラーが出る場合

上記のようにタスクの実行がエラーで終了する場合があり、これはM1 Macで実行の場合に発生します。私がまさにこれでハマりました。解決方法を別の記事に記載していますので同問題の場合はそちらで対処ください

ECS で利用するコンテナを M1 Macでビルドする際の注意ECS で利用するコンテナを M1 Macでビルドする際の注意

S3にも正しく保存されているか確認しましょう。

% aws s3 ls s3://hiyuzawa-wadai-crawler
2021-05-08 14:13:19        716 wadai_20210508.json
% aws s3 cp s3://hiyuzawa-wadai-crawler/wadai_20210508.json .
 % cat ./wadai_20210508.json | jq .
[
  "すばるくん",
  "渋谷すばる",
  "特急燭台切光忠",
...
  "メンズノンノ"
]

タスクを定期実行

単体でのタスク実行が確認できたら、ようやく最後の項目のタスクの定期実行に移れます。今回クローラは毎時0分で1時間間隔で実行することにします。

タスクの定期実行はクラスタの画面のタブの「タスクのスケジューリング」で行います。「作成」を選択すると定期実行のタスクが定義できます。スケジュールタスクルール名、ターゲットIDは「wadai-crawler-hourly-task」という名称にしスケジュールタイプを Cron式、その定義を cron(0 * * * ? *) とします。これで毎時0分にTaskが実行されます。

他の定義はタスクを単体実行した際に選択した値と同じにします。FARGATE, 実行ロール, VPC, サブネット, パブリックIPの割当はENABLEです。

正常にスケジューリングタスクが登録されると下記のようになります。

確認

次の0分までゆっくり待ちましょう。その間に実行時のログの確認方法を記します。タスクの単体実行でStoppedのタスク詳細から確認しましたが、そこにリストされるのは終了直後のみです。そこで表示できなくなったログを確認したい場合(定期実行のログを後で確認したい場合など)の方法を記します。答えはCloudWatchで確認できます。

CloudWatchの右カラムからログ > ロググループを選択。今回のケースだと「/ecs/wadai-crawler」というグループが見つかるはずです。それを選択して、ログストリームを確認するとECSの wadai-crawlerが実行された各回のログ(手動実行、定期実行どちらも)が確認できます

0時のログおよびS3へのファイル送信が確認できたでしょうか。

不具合修正

おそらく手順通りにやるとすぐに気がつくと思います。S3の保存名をwadai_YYYYMMDD.json としてしまっているため、せっかく毎時に実行しているのに、毎回上書きしてしまっています。

これを修正します。修正するには、クローラのS3のファイル名を決定するロジックを修正します。

from datetime import datetime

def upload_s3(file):
    ...
    s3file_name = "{}_{}.{}".format(file.split(".", 1)[0], datetime.now().strftime("%Y%m%d%H"), file.split(".", 1)[1])
    ...

クローラの修正後、ローカルで確認してみましょう。さらに潜在的なバグに気がつくと思います。Docker内部のTimeZoneがUTCで動いていますので、時間の処理がJSTにはなっていません。時間が9時間前のもので出力されるはずです。 これの解決は色々方法はあるようですが、Dockerの実行環境のTimeZoneを変更するのが一番良さそうなのでそれで対応します。Dockerfileの環境変数に一行追加します。

ENV TZ Asia/Tokyo

これで問題なくJSTで日付時間までのファイルが作成されるはずです。

Docker Imageの再ビルド、ECRに再プッシュです。 (ECR の latestを上書きすればその他ECSのその他の定義は変更不要なはずです。次の実行から新Imageで実行されます。

% aws s3 ls s3://hiyuzawa-wadai-crawler
2021-05-08 15:28:53        599 wadai_2021050815.json
2021-05-08 16:01:22        618 wadai_2021050816.json
2021-05-08 17:01:02        558 wadai_2021050817.json

% aws s3 sync s3://hiyuzawa-wadai-crawler/ .
download: s3://hiyuzawa-wadai-crawler/wadai_2021050815.json to ./wadai_2021050815.json
download: s3://hiyuzawa-wadai-crawler/wadai_2021050817.json to ./wadai_2021050817.json
download: s3://hiyuzawa-wadai-crawler/wadai_2021050816.json to ./wadai_2021050816.json

% cat ./wadai_2021050816.json | jq . | head
[
  "すばるくん",
  "渋谷すばる 結婚",
  "1121人",
  "oneST",
  "マルティネス",
  "松山竜平",
  "ウメハラ",
  "始球式",
  "特急燭台切光忠",

補足

これでひとまずクローラは完成したが、いまいち気になるところは今回のためにAWSのアプリケーションアカウントをセットしたところ。これのためにDockerfileにキーを設定せねばならないのでいまいち。

これを回避するにはおそらく、ECSのタスクの実行ロールに足してS3のPut権限を加えることで解決する(アプリケーションアカウントは不要になる)と思われる。

が、この方法をとるとローカルのDocker環境でS3までのPutの確認ができなくなる。ということでベストプラクティスがよく分からない。。