Misskeyで天気予報botを作ってみた

## 追記

### 2025-05-07

GitHub Actionsのログについて、PublicリポジトリのログはGitHubアカウントがあれば誰でも確認できるようです。
awsコマンドの実行結果もログに出力されており、その中にはMisskeyとWeatherAPI.comのアクセスキーを含む環境変数も出力されていました。
このため、この記事のコードを含むGitHubリポジトリはPrivateに変更させていただきました。
アクセスキーについてはすでに変更済です。

## 概要

毎朝テレビで天気予報を確認するのが日課でしたが、最近はそれを見逃すことが多く、何か代わりになるものはないかと考えていました。
自分はおひとり様用のMisskeyサーバーをもっており、そこのタイムラインを毎朝確認しています。
そのため、自分のMisskeyサーバーに天気予報を投稿するbotを作ってみることにしました。

要件は以下のとおりです。

- 毎朝決まった時間に天気予報を投稿する
- 指定した都市の一日予報と一時間ごとの予報がほしい

この要件を満たすため、インフラとしてはAWS Lambdaを使用し、それを定期実行すればよさそうです。
インフラ作成はTerraformを用いてコード化します。

MisskeyにはPython用のSDKがあるので、アプリを作成する言語としてはPythonを採用します。
その他の言語向けのSDKもあるので、興味がある方はぜひ確認してみてください。
https://misskey-hub.net/ja/docs/for-developers/api/libraries/

また、Lambdaに毎回手動でデプロイするのは面倒なので、GitHub Actionsを用いて自動化します。

肝心の天気予報データの取得元ですが、[WeatherAPI.com](https://www.weatherapi.com/)を使うと要件を満たすデータを取得できることがわかったので、このAPIを使用することにします。
無料のFreeプランでも今回のユースケースであれば十分な機能が揃っています。

この記事は以下の三部構成で進めていきます。

1. アプリ(Python, Docker)
1. インフラ(Terraform)
1. CI/CD (GitHub Actions)

コードはGitHubで公開しています。
https://github.com/maeda6uiui/misskey-weather-bot

### 環境情報

```
$ cat /etc/os-release 
PRETTY_NAME="Zorin OS 17.2"
NAME="Zorin OS"
VERSION_ID="17"
VERSION="17.2"
VERSION_CODENAME=jammy
ID=zorin
ID_LIKE="ubuntu debian"
HOME_URL="https://zorin.com/os/"
SUPPORT_URL="https://help.zorin.com/"
BUG_REPORT_URL="https://zorin.com/os/feedback/"
PRIVACY_POLICY_URL="https://zorin.com/legal/privacy/"
UBUNTU_CODENAME=jammy
```

## アプリ

```
$ python -V
Python 3.10.12
```

```text:requirements.txt
certifi==2024.8.30
charset-normalizer==3.4.0
idna==3.10
Misskey.py==4.1.0
numpy==2.1.2
pandas==2.2.3
python-dateutil==2.9.0.post0
pytz==2024.2
requests==2.32.3
six==1.16.0
tzdata==2024.2
urllib3==2.2.3
```

### 共通部分

天気予報を取得する、Misskeyに投稿する、などの機能をクラスにまとめてみました。
このように実行環境(ローカルとクラウド)に依存しない部分をまとめておくと、動作確認を行ったりコード修正を行うときに便利です。

```python:common.py
import pandas as pd
import requests
from logging import getLogger, Logger
from misskey import Misskey


class WeatherForecastPoster(object):
    """
    天気予報をMisskeyに投稿するクラス
    """

    def __init__(
        self,
        weather_api_key: str,
        misskey_server_url: str,
        misskey_access_token: str,
        weather_conditions_filepath: str = "./Data/weather_conditions.csv",
        logger: Logger = None,
    ):
        """
        Parameters
        ----------
        weather_api_key: str
            Weather APIのAPIキー
        misskey_server_url: str
            MisskeyサーバーのURL
        misskey_access_token: str
            Misskeyのアクセストークン
        weather_conditions_filepath (optional): str
            Weather APIで返されるコードとそれに対応する絵文字の一覧表のファイルパス
        logger (optional): Logger
            ロガー
        """
        self._weather_api_key = weather_api_key
        self._mk = Misskey(address=misskey_server_url, i=misskey_access_token)

        self._df_weather_conditions = pd.read_csv(
            weather_conditions_filepath, encoding="utf-8"
        )

        if logger is not None:
            self._logger = logger
        else:
            self._logger = getLogger(__name__)

    def _get_weather_forecast(self, q: str, days: int) -> dict[str, pd.DataFrame]:
        """
        天気予報のデータを取得する

        Parameters
        ----------
        q: str
            クエリパラメータ
        days: int
            天気予報を取得する日数

        Returns
        ----------
        dict[str,DataFrame]
            location: 位置データ
            daily: 1日ごとの天気予報データ
            hourly: 1時間ごとの天気予報データ
        """
        response = requests.get(
            "https://api.weatherapi.com/v1/forecast.json",
            headers={"key": self._weather_api_key},
            params={"q": q, "days": days, "lang": "ja"},
        )
        if response.status_code != 200:
            raise RuntimeError(
                f"Weather APIの実行に失敗しました: {response.status_code}"
            )

        data = response.json()

        location = data["location"]
        data_location = {
            "name": [location["name"]],
            "region": [location["region"]],
            "country": [location["country"]],
        }
        df_location = pd.DataFrame(data_location)

        data_daily = {
            "date": [],
            "maxtemp_c": [],
            "mintemp_c": [],
            "avgtemp_c": [],
            "condition_code": [],
            "condition_text": [],
            "sunrise": [],
            "sunset": [],
        }
        data_hourly = {
            "time": [],
            "temp_c": [],
            "condition_code": [],
            "condition_text": [],
        }

        for forecastday in data["forecast"]["forecastday"]:
            # 1日ごとのデータ
            date = forecastday["date"]
            maxtemp_c = forecastday["day"]["maxtemp_c"]
            mintemp_c = forecastday["day"]["mintemp_c"]
            avgtemp_c = forecastday["day"]["avgtemp_c"]
            condition_code = forecastday["day"]["condition"]["code"]
            condition_text = forecastday["day"]["condition"]["text"]
            sunrise = forecastday["astro"]["sunrise"]
            sunset = forecastday["astro"]["sunset"]

            data_daily["date"].append(date)
            data_daily["maxtemp_c"].append(maxtemp_c)
            data_daily["mintemp_c"].append(mintemp_c)
            data_daily["avgtemp_c"].append(avgtemp_c)
            data_daily["condition_code"].append(condition_code)
            data_daily["condition_text"].append(condition_text)
            data_daily["sunrise"].append(sunrise)
            data_daily["sunset"].append(sunset)

            # 1時間ごとのデータ
            for hour in forecastday["hour"]:
                time = hour["time"]
                temp_c = hour["temp_c"]
                condition_code = hour["condition"]["code"]
                condition_text = hour["condition"]["text"]

                data_hourly["time"].append(time)
                data_hourly["temp_c"].append(temp_c)
                data_hourly["condition_code"].append(condition_code)
                data_hourly["condition_text"].append(condition_text)

        df_daily = pd.DataFrame(data_daily)
        df_hourly = pd.DataFrame(data_hourly)

        return {"location": df_location, "daily": df_daily, "hourly": df_hourly}

    def _create_misskey_note(self, text: str, visibility: str) -> str:
        """
        Misskeyにノートを作成する

        Parameters
        ----------
        text: str
            ノートの内容
        visibility: str
            ノートの公開範囲

        Returns
        ----------
        str
            ノートのID
        """
        note = self._mk.notes_create(text=text, visibility=visibility)
        note_id = note["createdNote"]["id"]

        return note_id

    def _get_condition_emoji(self, condition_code: int) -> str:
        """
        天気を表す絵文字を返す

        Parameters
        ----------
        condition_code: int
            天気のコード

        Returns
        ----------
        str
            天気を表す絵文字
        """
        df_weather_condition = self._df_weather_conditions
        record = df_weather_condition[df_weather_condition["code"] == condition_code]
        if record.empty:
            return ""

        return record["emoji"].item()

    def post_weather_forecast(self, q: str, visibility: str = "public"):
        """
        天気予報をMisskeyに投稿する

        Parameters
        ----------
        q: str
            Weather APIを実行するときのクエリパラメータ
        visibility: str
            ノートの公開範囲
        """
        dfs = self._get_weather_forecast(q, 1)

        df_location = dfs["location"]
        df_daily = dfs["daily"]
        df_hourly = dfs["hourly"]

        self._logger.debug(df_location)
        self._logger.debug(df_daily)
        self._logger.debug(df_hourly)

        location_name = df_location["name"].item()

        date = df_daily["date"].item()
        condition_code = df_daily["condition_code"].item()
        condition_text = df_daily["condition_text"].item()
        avgtemp_c = df_daily["avgtemp_c"].item()
        mintemp_c = df_daily["mintemp_c"].item()
        maxtemp_c = df_daily["maxtemp_c"].item()

        condition_emoji = self._get_condition_emoji(condition_code)

        text = (
            f"{date}の{location_name}の天気予報\n\n"
            f"{condition_emoji}{condition_text}\n"
            f"{avgtemp_c}℃ (平均) / {mintemp_c}℃ (最低) / {maxtemp_c}℃ (最高)"
        )
        note_id = self._create_misskey_note(text, visibility)
        self._logger.info(f"ノートID (1日ごとの天気予報): {note_id}")

        text = f"{date}の{location_name}の天気予報(1時間ごと)\n\n"
        for _, row in df_hourly.iterrows():
            time: str = row["time"]
            time = time.split(" ")[1]

            temp_c = row["temp_c"]
            condition_code = row["condition_code"]
            condition_text = row["condition_text"]

            condition_emoji = self._get_condition_emoji(condition_code)

            text += f"{time} / {temp_c}℃ / {condition_emoji}{condition_text}\n"

        note_id = self._create_misskey_note(text, visibility)
        self._logger.info(f"ノートID (1時間ごとの天気予報): {note_id}")
```

天気予報の文字列だけだとぱっと見でわかりにくいので、天気に対応する絵文字を出力するようにしています。
APIが返す[天気の一覧](https://www.weatherapi.com/docs/#weather-icons)が公開されているので、それをもとに力技で絵文字との対応表を作成しました。
データベースを作るほどではないので、天気と絵文字の対応はCSVファイルから読み込むようにします。

天気と絵文字の対応表の一部

```text:weather_conditions.csv
code,day,night,icon,emoji
1000,Sunny,Clear,113,"☀"
1003,"Partly cloudy","Partly cloudy",116,"☁"
1006,Cloudy,Cloudy,119,"☁"
1009,Overcast,Overcast,122,"☁"
1030,Mist,Mist,143,"🌫"
```

### ローカル実行用のコード

ベースとなる機能ができたので、まずはローカル環境でテストします。
自分が管理しているMisskeyサーバーにbot用のアカウントを作成し、アクセスキーを発行します。
ローカル環境での実行時はAPIキーなどをコマンドライン引数で指定することにします。

```python:main_local.py
import argparse
import yaml
from logging import getLogger, config
from pathlib import Path

from common import WeatherForecastPoster


def main(args):
    # コマンドライン引数
    weather_api_key: str = args.weather_api_key
    forecast_query_param: str = args.forecast_query_param
    misskey_server_url: str = args.misskey_server_url
    misskey_access_token: str = args.misskey_access_token

    # ログファイルを保存するディレクトリを作成する
    logging_dir = Path("./Log")
    logging_dir.mkdir(exist_ok=True)

    # ロガーをセットアップする
    with open("./logging_config.yaml", "r", encoding="utf-8") as r:
        logging_config = yaml.safe_load(r)

    config.dictConfig(logging_config)
    logger = getLogger(__name__)

    # Misskeyに天気予報を投稿する
    wfp = WeatherForecastPoster(
        weather_api_key, misskey_server_url, misskey_access_token, logger=logger
    )
    try:
        wfp.post_weather_forecast(forecast_query_param, visibility="specified")
    except Exception as e:
        logger.error(f"処理中にエラーが発生しました: {e}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("-wk", "--weather-api-key", type=str)
    parser.add_argument("-q", "--forecast-query-param", type=str, default="Tokyo")
    parser.add_argument("-u", "--misskey-server-url", type=str)
    parser.add_argument("-mk", "--misskey-access-token", type=str)
    args = parser.parse_args()

    main(args)
```

ローカル環境での動作確認の段階では、ノートの公開範囲は`specified`にしておきます。
`specified`はノートを公開するユーザーIDを指定する場合はダイレクトメッセージに相当する機能ですが、ユーザーIDを指定しない場合は投稿者本人のみが閲覧できるノートになります。

`-q`にはWeatherAPI.comのAPIを実行するときに渡すクエリパラメータを指定します。
今回は都市名を指定していますが、[ドキュメント](https://www.weatherapi.com/docs/#intro-request-param)によるとその他にも緯度・経度やIPアドレスを指定することもできるようです。

このコードを実行すると、Misskeyにノートが作成されることを確認できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3915103/bb47b7eb-9efe-dd42-a4b8-49885c7416dc.png)

### Lambda実行用のコード

Lambdaで実行するコードも作成します。
こちらは環境変数から必要な値を取得します。

```python:main.py
import logging
import os
from logging import getLogger

from common import WeatherForecastPoster

WEATHER_API_KEY = os.environ["WEATHER_API_KEY"]
FORECAST_QUERY_PARAM = os.environ["FORECAST_QUERY_PARAM"]
MISSKEY_SERVER_URL = os.environ["MISSKEY_SERVER_URL"]
MISSKEY_ACCESS_TOKEN = os.environ["MISSKEY_ACCESS_TOKEN"]

logger = getLogger(__name__)
logger.setLevel(logging.INFO)


def lambda_handler(event, context):
    # Misskeyに天気予報を投稿する
    wfp = WeatherForecastPoster(
        WEATHER_API_KEY, MISSKEY_SERVER_URL, MISSKEY_ACCESS_TOKEN, logger=logger
    )
    try:
        wfp.post_weather_forecast(FORECAST_QUERY_PARAM)
    except Exception as e:
        logger.error(f"処理中にエラーが発生しました: {e}")
```

### Dockerイメージの作成

依存するライブラリなどを含めてLambdaをデプロイする場合はDockerイメージを使うのが便利なので、Dockerfileも用意しておきます。

```dockerfile:Dockerfile
FROM public.ecr.aws/lambda/python:3.10

COPY main.py common.py requirements.txt ${LAMBDA_TASK_ROOT}
COPY ./Data/* ${LAMBDA_TASK_ROOT}/Data/

RUN pip install -r requirements.txt

CMD ["main.lambda_handler"]
```

## インフラ

```
$ terraform -v
Terraform v1.9.8
on linux_amd64
```

Terraformを用いてAWS Lambdaとその関連リソースを作成します。
Lambdaのデプロイを行うためにGitHub Actionsを使うので、そのためのIAMロールも作成します。

ディレクトリ構成は以下のとおりです。

```
.
├── env
│   └── prod
│       ├── info.tf
│       ├── main.tf
│       ├── outputs.tf
│       ├── prod.tfplan
│       └── providers.tf
└── modules
    ├── account_info
    │   ├── main.tf
    │   └── outputs.tf
    ├── github_actions
    │   ├── main.tf
    │   ├── outputs.tf
    │   └── variables.tf
    └── misskey_weather_bot
        ├── cloudwatch
        │   ├── main.tf
        │   ├── outputs.tf
        │   └── variables.tf
        ├── ecr
        │   ├── main.tf
        │   ├── outputs.tf
        │   ├── push_temp_image.sh
        │   └── variables.tf
        ├── eventbridge
        │   ├── main.tf
        │   └── variables.tf
        ├── iam
        │   ├── main.tf
        │   ├── outputs.tf
        │   └── variables.tf
        ├── lambda
        │   ├── main.tf
        │   ├── outputs.tf
        │   └── variables.tf
        ├── main.tf
        ├── outputs.tf
        └── variables.tf

```

envディレクトリ配下に各環境用のディレクトリを作ります。
今回は本番環境だけなのでディレクトリは一つですが、一般的には開発環境やステージング環境もあると思うので、環境の分だけディレクトリを分けます。

実際のリソースの定義はmodulesディレクトリ配下にあります。
天気予報bot関連のリソースを定義するモジュール(misskey_weather_bot)、GitHub Actions関連のリソースを作成するモジュール(github_actions)、AWSアカウントの情報を取得するモジュール(account_info)があります。

Terraformのディレクトリ構成に唯一の正解があるわけではないと思いますが、今回のようなディレクトリ構成は中規模から大規模なシステムで有用だと思います。
必ずしもこのような構成にする必要はなく、たとえば、以下のような「平らな」構成にすることもできます。

```
.
├── ecr.tf
├── iam.tf
└── lambda.tf
```

モジュール間の値の受渡しがないため、**リソース数が少ないうちは**わかりやすい構成だと思います。
以下のような場合にはこのような平らな構成が有用かもしれません。

- 技術ブログに掲載するショーケース的なコード
- 短期間で作成するプロトタイプ
- 小規模なシステムを作成する場合

:::note warn
機能追加などでシステムの規模が大きくなる場合は、できるだけ早い段階で機能ごとのモジュールに分けることが望ましいと思います。
平らな構成のままゴリ押しでリソースを追加していくと、コードのメンテナンスコストが上がって、そのうち誰も触りたがらないコードができあがります。
:::

この記事では、天気予報botのTerraformコードをすべて紹介するのは冗長なため、いくつかをピックアップして紹介したいと思います。
コード全体はGitHubで公開しているため、興味のある方は確認してみてください。
https://github.com/maeda6uiui/misskey-weather-bot/tree/main/Terraform

### providers.tf

使用するプロバイダーやステートファイルを保存しておくS3バケットの指定を行っています。
awsプロバイダーの機能として、作成する各リソースにセットするデフォルトのタグ(`default_tags`)を指定することができます。

```terraform:env/prod/providers.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~>5.0"
    }
    tls = {
      source  = "hashicorp/tls"
      version = "~>4.0"
    }
  }

  backend "s3" {
    bucket = "misskey-weather-bot-tfstate"
    region = "ap-northeast-1"
    key    = "prod.tfstate"
  }

  required_version = "~>1.9"
}

provider "aws" {
  region = "ap-northeast-1"

  default_tags {
    tags = {
      Service   = local.service
      Env       = local.env
      ManagedBy = local.managed_by
    }
  }
}
```

### account_infoモジュール

Terraformを実行しているAWSアカウントの情報を取得するためのモジュールです。
ここで取得した情報を別のモジュールで使用します。

```terraform:modules/account_info/main.tf
data "aws_caller_identity" "current" {

}

data "aws_region" "current" {

}
```

```terraform:modules/account_info/outputs.tf
output "aws" {
  value = {
    account_id = data.aws_caller_identity.current.account_id
    region     = data.aws_region.current.name
  }
}
```

AWSのアカウントIDとリージョンを取得しています。

今回のユースケースでは、env/prod配下のファイルにアカウントIDとリージョンを変数として定義しておいて、それを各モジュールの引数として渡す、という形でも問題ないと思います。
今回のようにdataを使って必要な情報を取得すると、その情報はモジュールのユーザーに指定してもらう必要がなくなるので、他の人に使ってもらうことが前提のモジュール開発では有用な機能だと思います。

### ECRリポジトリの作成

ECRリポジトリを作成した後、[terraform_data](https://developer.hashicorp.com/terraform/language/resources/terraform-data)を使ってそのリポジトリに仮のイメージをPushします。
これまでは[null_resource](https://registry.terraform.io/providers/hashicorp/null/latest/docs/resources/resource)というリソースを使っていましたが、Terraform 1.4以降ではterraform_dataを使うようにとの記載があるので、terraform_dataを使うことにします。

DockerイメージをデプロイするLambdaの場合、Lambda作成時にそのイメージが存在しないとエラーになってしまうため、ここで仮のイメージをPushしています。
ここでPushするイメージは何でもいいため、とりあえずhello-worldのイメージを使用します。

```terraform:modules/misskey_weather_bot/ecr/main.tf
resource "aws_ecr_repository" "main" {
  name                 = "lambda/misskey-weather-bot"
  image_tag_mutability = "IMMUTABLE"
}

resource "terraform_data" "main" {
  triggers_replace = [
    aws_ecr_repository.main.arn
  ]

  provisioner "local-exec" {
    command = "bash ${path.module}/push_temp_image.sh"
    environment = {
      AWS_REGION     = var.aws.region
      AWS_ACCOUNT_ID = var.aws.account_id
      REPOSITORY_URL = aws_ecr_repository.main.repository_url
    }
  }
}
```

実際にイメージをPushするシェルスクリプトは以下のとおりです。

```bash:modules/misskey_weather_bot/ecr/push_temp_image.sh
#!/bin/bash

aws ecr get-login-password --region ${AWS_REGION} | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com
docker pull hello-world:latest
docker tag hello-world:latest ${REPOSITORY_URL}:temp
docker push ${REPOSITORY_URL}:temp
```

自分が実行したときは問題ありませんでしたが、仮のイメージがPushされるより前にLambdaが作成されるような動作になると、Lambdaの作成時に失敗すると思います。
この場合は再度`terraform apply`を実行するか、modules/misskey_weather_bot/main.tfでlambdaモジュールがecrモジュールに依存することを`depends_on`で明示してやるとうまくいくと思います。

```terraform
module "lambda" {
  source = "./lambda"

  (引数は省略)

  depends_on = [
    module.ecr
  ]
}
```

### Lambdaの作成

`image_uri`には先程Pushした仮のイメージを指定します。
`image_uri`は後で実際のイメージをデプロイすると変更されますが、その変更はTerraform側では無視してほしい(上書きしないでほしい)ため、`ignore_changes`に追加しています。

また、今回の環境変数についてはAPIキーを含んでいてコード内に記載するのが望ましくないため、手動でセットする運用とし、Terraform側ではその変更を無視するようにします。

```terraform:modules/misskey_weather_bot/lambda/main.tf
resource "aws_lambda_function" "main" {
  function_name = "${var.name_prefix}-lambda-${var.env}"

  role = var.lambda_role_arn

  #実際のイメージはGitHub Actionsでデプロイする
  package_type = "Image"
  image_uri    = "${var.repository_url}:temp"

  timeout     = var.lambda_config.timeout
  memory_size = var.lambda_config.memory_size

  lifecycle {
    ignore_changes = [
      image_uri,
      environment
    ]
  }
}
```

### GitHub Actions関連のリソースを作成

まずOIDCプロバイダーを作成します。
以下の記事を参考にしました。
https://zenn.dev/yukin01/articles/github-actions-oidc-provider-terraform

```terraform:modules/github_actions/main.tf
data "http" "github_actions" {
  url = "https://token.actions.githubusercontent.com/.well-known/openid-configuration"
}

data "tls_certificate" "github_actions" {
  url = jsondecode(data.http.github_actions.response_body).jwks_uri
}

resource "aws_iam_openid_connect_provider" "github_actions" {
  url             = "https://token.actions.githubusercontent.com"
  client_id_list  = ["sts.amazonaws.com"]
  thumbprint_list = [data.tls_certificate.github_actions.certificates[0].sha1_fingerprint]
}
```

ただ、GitHub ActionsとAWSを連携させる場合には`thumbprint_list`の値は使われなくなったという記事もあるように、`thumbprint_list`には仮の値をセットしておいても問題なく動作するのかもしれません。

- https://zenn.dev/miyajan/articles/github-actions-support-openid-connect
- https://github.blog/changelog/2023-07-13-github-actions-oidc-integration-with-aws-no-longer-requires-pinning-of-intermediate-tls-certificates/

GitHub Actionsで使うIAMロールを作成します。
`Condition`の部分でこのIAMロールを使用できるリポジトリを制限しています。

```terraform:modules/github_actions/main.tf
resource "aws_iam_role" "github_actions" {
  name = "${var.name_prefix}-github-actions-${var.env}"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = "sts:AssumeRoleWithWebIdentity"
        Principal = {
          Federated = aws_iam_openid_connect_provider.github_actions.arn
        }
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
          }
          StringLike = {
            "token.actions.githubusercontent.com:sub" = "repo:${var.github_info.username}/${var.github_info.repo_name}:*"
          }
        }
      }
    ]
  })
}
```

GitHub Actionsで操作したいリソースに対するポリシーを作成してからIAMロールにアタッチします。
今回の場合はECRにイメージをPushする権限と、Lambdaのコード(image_uri)を更新する権限が必要です。

```terraform:modules/github_actions/main.tf
resource "aws_iam_policy" "allow_github_actions_access_to_ecr" {
  name = "${var.name_prefix}-allow-github-actions-access-to-ecr-${var.env}"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "ecr:UploadLayerPart",
          "ecr:PutImage",
          "ecr:InitiateLayerUpload",
          "ecr:CompleteLayerUpload",
          "ecr:BatchGetImage",
          "ecr:BatchCheckLayerAvailability"
        ]
        Resource = var.misskey_weather_bot.ecr.main.arn
      },
      {
        Effect   = "Allow"
        Action   = "ecr:GetAuthorizationToken"
        Resource = "*"
      }
    ]
  })
}

resource "aws_iam_policy" "allow_github_actions_access_to_lambda" {
  name = "${var.name_prefix}-allow-github-actions-access-to-lambda-${var.env}"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = "lambda:UpdateFunctionCode"
        Resource = var.misskey_weather_bot.lambda.main.arn
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "allow_github_actions_access_to_ecr" {
  role       = aws_iam_role.github_actions.name
  policy_arn = aws_iam_policy.allow_github_actions_access_to_ecr.arn
}

resource "aws_iam_role_policy_attachment" "allow_github_actions_access_to_lambda" {
  role       = aws_iam_role.github_actions.name
  policy_arn = aws_iam_policy.allow_github_actions_access_to_lambda.arn
}
```

### env/prod/main.tf

作成した各モジュールに引数を渡して実行できるようにします。

```terraform:env/prod/main.tf
module "account_info" {
  source = "../../modules/account_info"
}

module "misskey_weather_bot" {
  source = "../../modules/misskey_weather_bot"

  name_prefix = local.service
  env         = local.env
  aws         = module.account_info.aws

  lambda_config = {
    timeout     = 15
    memory_size = 128
  }
  schedule_expression = "cron(0 22 * * ? *)"
}

module "github_actions" {
  source = "../../modules/github_actions"

  name_prefix = local.service
  env         = local.env

  misskey_weather_bot = module.misskey_weather_bot

  github_info = {
    username  = "maeda6uiui"
    repo_name = "misskey-weather-bot"
  }
}
```

### 実行

plan内容を確認して問題なさそうならapplyします。

```
$ terraform plan -out prod.tfplan
$ terraform apply prod.tfplan
```

Lambda関数には以下の値を環境変数としてセットしておきます。

- WEATHER_API_KEY
- FORECAST_QUERY_PARAM
- MISSKEY_SERVER_URL
- MISSKEY_ACCESS_TOKEN

## CI/CD

Lambda関数のデプロイを行うGitHub Actionsのワークフローを作成します。
中心となる機能をまとめたReusable workflowを作成し、このワークフローにデプロイに必要な値をパラメータとして渡す形にします。

今回は本番環境のみなので恩恵が少ないですが、開発環境、ステージング環境...、というようにデプロイ対象の環境が増えていった際に、複数のワークフローに同じ処理を記載する必要がなくなり、処理の見通しがよくなると思います。
この構成であれば、新しい環境が増えた際にも、Reusable workflowを呼び出すワークフローを追加するだけで済みます。

今回のReusable workflowを呼び出す本番環境用のワークフローは以下のとおりです。
Lambda関数の名前、ECRリポジトリの名前、AWSを操作するためのIAMロールのARNをパラメータとして渡します。

```yaml:.github/workflows/deploy-lambda-prod.yml
name: Deploy Lambda to prod environment

on:
  push:
    branches:
      - main
    paths:
      - Lambda/**
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

jobs:
  deploy:
    uses: ./.github/workflows/deploy-lambda.yml
    with:
      lambdaFunctionName: misskey-weather-bot-lambda-prod
      ecrRepoName: lambda/misskey-weather-bot
    secrets:
      awsDeploymentRoleArn: ${{secrets.AWS_DEPLOYMENT_ROLE_ARN_PROD}}
```

IAMロールのARNはあらかじめGitHubリポジトリのシークレットに値をセットしておきます。
GitHubの画面からセットすることもできますが、自分は`gh` (GitHub CLI)をよく使っています。
以下のコマンドで、`MY_SECRET`という名前で値が`secret value`のシークレットが作成されます。

```
$ gh secret set MY_SECRET --body "secret value"
✓ Set Actions secret MY_SECRET for maeda6uiui/misskey-weather-bot
```

:::note info
上記のコマンドはローカルリポジトリのディレクトリ内で実行します。
:::

Reusable workflowは以下のとおりです。
Dockerイメージのbuildとpushを行い、Lambda関数のイメージURLをそのイメージのものに変更します。

```yaml:.github/workflows/deploy-lambda.yml
name: Deploy Lambda

on:
  workflow_call:
    inputs:
      lambdaFunctionName:
        type: string
        required: true
      ecrRepoName:
        type: string
        required: true
    secrets:
      awsDeploymentRoleArn:
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-region: ap-northeast-1
          role-to-assume: ${{secrets.awsDeploymentRoleArn}}
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      - name: Build and push
        uses: docker/build-push-action@v6
        env:
          REGISTRY: ${{steps.login-ecr.outputs.registry}}
          IMAGE_TAG: ${{github.sha}}
        with:
          platforms: linux/amd64
          context: Lambda
          push: true
          tags: ${{env.REGISTRY}}/${{inputs.ecrRepoName}}:${{env.IMAGE_TAG}}
          provenance: false
      - name: Update Lambda function
        env:
          REGISTRY: ${{steps.login-ecr.outputs.registry}}
          IMAGE_TAG: ${{github.sha}}
        run: |
          aws lambda update-function-code \
            --function-name ${{inputs.lambdaFunctionName}} \
            --image-uri ${{env.REGISTRY}}/${{inputs.ecrRepoName}}:${{env.IMAGE_TAG}}
```

今回の用途なら[docker/build-push-action](https://github.com/docker/build-push-action)は使わずに`run`の中で`docker build`と`docker push`を実行するだけでも事足りますが、docker/build-push-actionを使うとマルチアーキテクチャイメージを作成することも可能です。

## まとめ

このbotを運用開始してから1か月ほど経ちますが、特に問題なく毎朝天気予報を投稿してくれています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3915103/d49e87cb-044a-f91f-5417-7d47295def03.png)

この記事では省略しましたが、実際にはアプリ->インフラ->CI/CDというきれいな一本道で開発が完了したわけではなく、その三つのステージを行ったり来たりしながら開発を進めました。

現状では一つの都市の天気予報を投稿するだけですが、全国の予報を投稿するようにしたり、あるいはこれを応用してニュースの投稿を行うようにする、ということも考えています。

今回の記事が誰かの参考になればうれしいです。