概要
毎朝テレビで天気予報を確認するのが日課でしたが、最近はそれを見逃すことが多く、何か代わりになるものはないかと考えていました。
自分はおひとり様用の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を使うと要件を満たすデータを取得できることがわかったので、このAPIを使用することにします。
無料のFreeプランでも今回のユースケースであれば十分な機能が揃っています。
この記事は以下の三部構成で進めていきます。
- アプリ(Python, Docker)
- インフラ(Terraform)
- 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
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に投稿する、などの機能をクラスにまとめてみました。
このように実行環境(ローカルとクラウド)に依存しない部分をまとめておくと、動作確認を行ったりコード修正を行うときに便利です。
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が返す天気の一覧が公開されているので、それをもとに力技で絵文字との対応表を作成しました。
データベースを作るほどではないので、天気と絵文字の対応は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キーなどをコマンドライン引数で指定することにします。
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を実行するときに渡すクエリパラメータを指定します。
今回は都市名を指定していますが、ドキュメントによるとその他にも緯度・経度やIPアドレスを指定することもできるようです。
このコードを実行すると、Misskeyにノートが作成されることを確認できました。
Lambda実行用のコード
Lambdaで実行するコードも作成します。
こちらは環境変数から必要な値を取得します。
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も用意しておきます。
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
モジュール間の値の受渡しがないため、リソース数が少ないうちはわかりやすい構成だと思います。
以下のような場合にはこのような平らな構成が有用かもしれません。
- 技術ブログに掲載するショーケース的なコード
- 短期間で作成するプロトタイプ
- 小規模なシステムを作成する場合
機能追加などでシステムの規模が大きくなる場合は、できるだけ早い段階で機能ごとのモジュールに分けることが望ましいと思います。
平らな構成のままゴリ押しでリソースを追加していくと、コードのメンテナンスコストが上がって、そのうち誰も触りたがらないコードができあがります。
この記事では、天気予報botのTerraformコードをすべて紹介するのは冗長なため、いくつかをピックアップして紹介したいと思います。
コード全体はGitHubで公開しているため、興味のある方は確認してみてください。
https://github.com/maeda6uiui/misskey-weather-bot/tree/main/Terraform
providers.tf
使用するプロバイダーやステートファイルを保存しておくS3バケットの指定を行っています。
awsプロバイダーの機能として、作成する各リソースにセットするデフォルトのタグ(default_tags
)を指定することができます。
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アカウントの情報を取得するためのモジュールです。
ここで取得した情報を別のモジュールで使用します。
data "aws_caller_identity" "current" {
}
data "aws_region" "current" {
}
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を使ってそのリポジトリに仮のイメージをPushします。
これまではnull_resourceというリソースを使っていましたが、Terraform 1.4以降ではterraform_dataを使うようにとの記載があるので、terraform_dataを使うことにします。
DockerイメージをデプロイするLambdaの場合、Lambda作成時にそのイメージが存在しないとエラーになってしまうため、ここで仮のイメージをPushしています。
ここでPushするイメージは何でもいいため、とりあえずhello-worldのイメージを使用します。
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するシェルスクリプトは以下のとおりです。
#!/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
で明示してやるとうまくいくと思います。
module "lambda" {
source = "./lambda"
(引数は省略)
depends_on = [
module.ecr
]
}
Lambdaの作成
image_uri
には先程Pushした仮のイメージを指定します。
image_uri
は後で実際のイメージをデプロイすると変更されますが、その変更はTerraform側では無視してほしい(上書きしないでほしい)ため、ignore_changes
に追加しています。
また、今回の環境変数についてはAPIキーを含んでいてコード内に記載するのが望ましくないため、手動でセットする運用とし、Terraform側ではその変更を無視するようにします。
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
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ロールを使用できるリポジトリを制限しています。
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)を更新する権限が必要です。
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
作成した各モジュールに引数を渡して実行できるようにします。
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をパラメータとして渡します。
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
上記のコマンドはローカルリポジトリのディレクトリ内で実行します。
Reusable workflowは以下のとおりです。
Dockerイメージのbuildとpushを行い、Lambda関数のイメージURLをそのイメージのものに変更します。
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は使わずにrun
の中でdocker build
とdocker push
を実行するだけでも事足りますが、docker/build-push-actionを使うとマルチアーキテクチャイメージを作成することも可能です。
まとめ
このbotを運用開始してから1か月ほど経ちますが、特に問題なく毎朝天気予報を投稿してくれています。
この記事では省略しましたが、実際にはアプリ->インフラ->CI/CDというきれいな一本道で開発が完了したわけではなく、その三つのステージを行ったり来たりしながら開発を進めました。
現状では一つの都市の天気予報を投稿するだけですが、全国の予報を投稿するようにしたり、あるいはこれを応用してニュースの投稿を行うようにする、ということも考えています。
今回の記事が誰かの参考になればうれしいです。