はじめに
みなさん、障害対応してますか?
障害対応に関しての私のモットーは、
アラートがとんで来たら、まずはコーヒーを淹れて、
鼻から抜ける豆の香りを堪能してからパソコンを開く事です。
おかげで、後々作成する謝罪文も芳醇でマイルドなものになります。
ことAWSの話しですが、ここ数ヶ月
コーヒーのフルーティな味わいを感じながら原因調査してると、
かなり序盤の方で
「これホスティング側の問題じゃない?」って頭に浮かぶ事があります。
しかしながらHealthDashboardは音沙汰なく、
「あれ、やっぱりこっちの問題???...」となり、もっかい色々みてみる、
なんてケースが多々あります。
そんな時ちらっとtwitterみると、
「AWS障害か〜〜仕方ないから今日休みにしない?」
「aws 落ちてんじゃん、ネトフリみて待ってよ」
といった考えられないツイートもあれば、
どのリージョンでどのリソースがあやしそうか探ってる人なんかもいます。
怪しいな、と思ったらtwitterがざわざわガヤガヤしてないか見てみるのも手だなと。
そこで、定期的に特定ワードが頻繁につぶやかれてる時、
その事を通知してくれる物を用意しておけば、
原因特定はそこそこに、次の一杯を淹れに影響調査の方に時間をさけるというものです。
という事で、そんな通知ボットを作りましょう。というのが前段。
長くなりましたが、目的は後付で、
cdkでecsタスクを定期実行する何かを作ってみようとしたという所が正直な所です。
docker、ecs周りの基本的内容の復習がほとんどです。
この記事でやってる事
- twitterのAPIを使って記事を検索するスクリプトをTypeScriptで用意
- 上記を定期実行するリソース一式をcdkで作る
目次
- twitter検索機能を作る
- docker化してみる
- fargateで動かしてみる
- まとめ
twitter検索機能を作る
まずはDeveloperアカウントの用意
詳しくは省きますが、公式さん の言う通りに進めます。
色々聞かれますが、作成したら Bearer Token
がもらえます。これ使うのでひかえておきます。
TypeScriptのセットアップ
公式さん を見ると、
APIのv2の TypeScript/JavaScriptSDK が存在しているようなので、
そちらを使う為にTypeScriptで書く事にします。
まずはプロジェクトのセットアップ
ちなみに自分の環境は以下
- PC: mac(Monterey) Apple Silicon
- node: 18.1.0
- npm: 8.8.0
- 手元のコーヒー: ブルーマウンテンブレンド 330円
# プロジェクト作成
mkdir app && cd app
# package.json作る
npm init
# typescript入れる
npm install --save-dev typescript
# tsconfig.json作る
npx tsc --init
いちいちトランスパイルするのも面倒なのでts-nodeを入れて、TypeScriptを直実行できるようにしておきます
npm install --save-dev ts-node
試しにtsファイルを作って、直実行してみます
const runningTest: string = "「He~~llo」(・ω| ←SigourneyWeaver"
console.log(runningTest)
npx ts-node hello.ts
> 「He~~llo」(・ω| ←SigourneyWeaver
問題なさそうですね。
npx ts-node
は、 ./node_modules/.bin/ts-node
と同義です。
いちいちmodulesを指定しなくてもいいという。npx、便利ですね。
インストールしてないモジュールも指定できるのだそう(使ったあと消える)
twitterのSDK入れる
次にsdkを公式さんの言う通りに入れます
npm install twitter-api-sdk
そしてこんな感じでindex.tsを改変します。
後々そうするので、tokenなんかは環境変数で渡してます
import {Client} from "twitter-api-sdk";
const bearerToken = process.env.BEARER_TOKEN as string;
const client = new Client(bearerToken);
const now = new Date();
now.setHours(now.getHours() - 1); // 試しに直近1時間までのツイートを対象に
async function search() {
const response = await client.tweets.tweetsRecentSearch({
'query': 'なかやまきんに君', // ここに検索したいワードを入れる
'start_time': now.toISOString(),
'tweet.fields': [
'created_at'
]
});
return response.data;
}
// 実行
search().then(tweets => {
if (tweets === undefined) {
console.log('取得結果なし')
process.exit(1);
}
for (const tweet of tweets) {
console.log(tweet);
}
});
走らせてみます。
BEARER_TOKEN=twitterに発行してもらったトークン \
npx ts-node index.ts
うん、動作良好ですね。
slack通知したいので、モジュール追加します
npm install @slack/web-api
ファイルがでかくなりそうなので、分けます。
import {Client} from "twitter-api-sdk";
import {WebClient} from "@slack/web-api";
export class TweetChecker {
private TWITTER_URL_BASE: string = 'https://twitter.com/dummy/status/TWEET_ID';
private readonly client: Client;
private readonly query: string;
private readonly slackToken: string;
private readonly slackChannel: string;
constructor(
bearerToken: string,
query: string,
slackToken: string,
slackChannel: string
) {
this.client = new Client(bearerToken);
this.query = query;
this.slackToken = slackToken;
this.slackChannel = slackChannel;
}
checkTweets() {
this.recentSearch().then(tweets => {
if (tweets === undefined) {
console.log('世界は平和でした。');
return;
}
let tweetUrls: Array<string> = [];
for (const tweet of tweets) {
let tweetUrl = this.TWITTER_URL_BASE.replace('TWEET_ID', tweet.id);
tweetUrls.push(tweetUrl);
}
this.post(tweetUrls).then(() => {
console.log('succeed');
});
});
}
private async recentSearch() {
const now = new Date();
now.setHours(now.getHours() - 12);
const response = await this.client.tweets.tweetsRecentSearch({
'query': this.query,
'max_results': 10,
'start_time': now.toISOString(),
'tweet.fields': [
'created_at',
'entities'
]
});
return response.data;
}
private async post(tweetUrls: Array<string>) {
let message = '';
message += '.....おや!?' + "\n"
message += "AWSのようすが.....!" + "\n\n"
message += " ↓ ↓ ↓ ↓" + "\n\n"
message += tweetUrls.join("\n");
const client = new WebClient(this.slackToken);
return await client.chat.postMessage({
channel: this.slackChannel,
text: message
});
}
}
index.tsは、環境変数受け取ってclassを呼ぶだけにしておきます
import {TweetChecker} from "./lib/TweetChecker";
const bearerToken = process.env.BEARER_TOKEN as string;
const searchQuery = process.env.SEARCH_QUERY as string;
const slackToken = process.env.SLACK_TOKEN as string;
const slackChannel = process.env.SLACK_CHANNEL as string;
const twitterClient = new TweetChecker(
bearerToken,
searchQuery,
slackToken,
slackChannel
);
twitterClient.checkTweets();
もっかい実行してみます。
↓↓↓
BEARER_TOKEN=twitterに発行してもらったトークン \
SEARCH_QUERY='AWS障害 OR AWS落ちた' \
SLACK_TOKEN=スラックトークン \
SLACK_CHANNEL=#すきなチャネル \
npx ts-node index.ts
OKOK。
まあここで色々対象ツイート絞ったり色々すべきですが、
はじめに言った通り、やりたかったのここからなので、
これはもうこれで終わりにします。
docker化してみる
次に、作ったものをdockerImageにします。
appディレクトリ直下にDockerfileを作成します。
今回はオフィシャルのnodeイメージを使います。
FROM node:latest
# ワークディレクトリを設定
WORKDIR /usr/run/app
# appディレクトリを一式コピー(不要な物はdockerignoreしてちょっとでも軽く)
COPY . .
# npm installする
RUN npm install
# npx ts-node index.ts走らせる
CMD [ "npx", "ts-node" , "index.ts" ]
COPY . .
はやりすぎですが、一応dockerignore
でイメージの中に入れたくないものは記載しておきます。
/node_modules
/Dockerfile
/.dockerignore
さあ、ビルドしてみます。
docker build -t search-tweet .
狙ったものが入ってるか、中入ってみてみましょう。
docker run -it --rm \
--name test-search-tweet search-tweet \
/bin/bash
root@90170d1c165f:/usr/run/app# ls
index.ts lib node_modules package-lock.json package.json tsconfig.json
うんうん、期待値。
ではrunしてみます。
先程直接指定していた環境変数は、コンテナ側に持っていく必要があるので、
こんな感じでファイルに書き出して、
run する時のオプションで指定してあげます。
BEARER_TOKEN=twitterに発行してもらったトークン
SEARCH_QUERY='AWS障害 OR AWS落ちた'
SLACK_TOKEN=スラックトークン
SLACK_CHANNEL=#すきなチャネル
では実行
docker run -it --rm \
--env-file=app.env \
--name test-search-tweet search-tweet
コンテナからも実行できました。
さあこれで準備は整いました。
いよいよfargateで動いてもらいましょう。
fargateで動かしてみる
さてawsリソース一式作るために、cdkの登場ですが、その前に
cdk is 何
まずはそこです。
いろいろ詳しい私の友人に聞いてみます。
知らないみたいです。
公式さんによると、
AWS Cloud Development Kitの略だそう。
ざっくりいうと、プログラミング言語でCloudFormationテンプレートが掛けるとの事。
同じようなリソースならぶ時はループとかで書けるって事です。やほーい。
何よりデフォで色々おすすめ初期設定が入ってるから、
簡単なリソースならめっちゃシンプルにかけます。
ここが便利であり怖いとこかなと。
cdkセットアップ
ではさっそくセットアップ。
今こんな感じのディレクトリになってるので、
app/ ←ここ
├── index.ts
├── lib
│ └── TweetChecker.ts
├── node_modules
├── package-lock.json
├── package.json
└── tsconfig.json
一個もどってcdkディレクトリ作ります。そして初期化。
いくつか言語使えますが、今回は同じくTypeScriptで。
cd ../ && mkdir cdk && cd cdk
# 初期化
cdk init --language typescript
cdk/
├── README.md
├── bin
│ └── cdk.ts
├── cdk.json
├── jest.config.js
├── lib
│ └── cdk-stack.ts
├── package-lock.json
├── package.json
├── test
│ └── cdk.test.ts
└── tsconfig.json
いろいろ入りました。 v2が入ったようです。
lib/cdk.ts
にスタックを記載し、 bin/cdk.ts
でそれらをnewする感じになります。
あと環境差分値なんかはcdk.json
に書くことになるかなと
ひとまずcdk bootstrap
cdkを使うには、cdk bootstrap
しておく必要があります。
リソースを作るリージョン毎に必要で、
実行するとデフォルトでは CDKToolkit
という名前のスタックが作成されます。
cdkの成果物はCloudFormationテンプレートですが、
CFnもテンプレートサイズがでかすぎる場合はs3に一度テンプレートを配置する必要があり、
その時などに使うs3バケットなんかがこれにより作成されます。
↓↓ 作られる ↓↓
↓↓ 中身 ↓↓
これで準備はokなので進みます
スタックの中身を書いていく
import {Stack, StackProps} from 'aws-cdk-lib';
import {Construct} from 'constructs';
import {SubnetType} from "aws-cdk-lib/aws-ec2";
import {Cluster, ContainerImage, FargatePlatformVersion, LogDriver} from "aws-cdk-lib/aws-ecs";
import {ScheduledFargateTask} from "aws-cdk-lib/aws-ecs-patterns";
import {Repository} from "aws-cdk-lib/aws-ecr";
import {Schedule} from "aws-cdk-lib/aws-events";
export interface SearchTweetStackProps extends StackProps {
bearerToken: string;
searchQuery: string;
slackToken: string;
slackChannel: string;
}
export class SearchTweetStack extends Stack {
constructor(scope: Construct, id: string, props: SearchTweetStackProps) {
super(scope, id, props);
// dockerイメージの配置場所を作る
const ecrRepository = new Repository(this, 'searchTweetRepository', {
repositoryName: "search-tweet-repo",
imageScanOnPush: true
});
// ECSのクラスタを作る
const cluster = new Cluster(this, 'SearchTweetECSCluster', {});
// タスクスケジュールを組む
const scheduledTask = new ScheduledFargateTask(this, 'SearchTweetScheduledTask', {
cluster: cluster,
platformVersion: FargatePlatformVersion.LATEST,
schedule: Schedule.expression('cron(0/10 * * * ? *)'),
scheduledFargateTaskImageOptions: {
image: ContainerImage.fromEcrRepository(ecrRepository, 'latest'),
memoryLimitMiB: 512,
cpu: 256,
logDriver: LogDriver.awsLogs({streamPrefix: 'ScheduledTaskLogs'}),
environment: {
BEARER_TOKEN: props.bearerToken,
SEARCH_QUERY: props.searchQuery,
SLACK_TOKEN: props.slackToken,
SLACK_CHANNEL: props.slackChannel,
},
}
});
}
}
驚くべきコード量のすくなさ,,,
まあ指定すべきvpcとかほとんどデフォルトっていうのもあるのですが、これだけです。
実際に作られるリソースはもっと多いです、ここで記載しているのは、
ECSクラスターとタスクスケジュールくらいなので余計スッキリしてますね。
そして今までさんざん無理やり渡していた環境変数ですが、
ここで scheduledFargateTaskImageOptions
のオプションとしてタスクに引き渡します。
まあこれで渡したかっただけです。
次にメインとなるcdk.tsを変更します
#!/usr/bin/env node
import 'source-map-support/register';
import * as cdk from 'aws-cdk-lib';
import {SearchTweetStack, SearchTweetStackProps} from '../lib/cdk-stack';
// props作成
const props: cdk.StackProps = {
env: {
account: process.env.CDK_DEFAULT_ACCOUNT,
region: "ap-northeast-1",
}
};
// cdk呼ぶ
const app = new cdk.App();
// cdkコマンドで指定したenvの値を取得
const env: string = app.node.tryGetContext("env");
// cdk_jsonに記載したcontext情報を取得
const envProps = app.node.tryGetContext(env);
// propsと自前propsを展開して一つにする
const customProps: SearchTweetStackProps = {
...props,
...envProps
};
// スタックを作る
new SearchTweetStack(
app,
`SearchTweetStack`,
customProps
);
ここ、環境変数をlibのtsに渡したいわけですが、
cdkのContextの仕組みを使って注入します。
{
"app": "npx ts-node --prefer-ts-exts bin/cdk.ts",
"~~~~~~~",
"割愛",
"~~~~~~~",
"context": {
"@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true,
"~~~~~~",
"割愛",
"~~~~~~",
"@aws-cdk/core:target-partitions": [
"aws",
"aws-cn"
],
"test": { <-- ここ
"bearerToken": "twitterから発行されたトークン",
"searchQuery": "AWS障害 OR AWS落ちた",
"slackToken": "スラックのトークン",
"slackChannel": "好きなチャネル"
}
}
}
こんな感じで記載しておくことで、
cdkコマンド実行時にcdk.jsonに書いといた値がとれるわけです。
今回機密情報記載しちゃってるのでNGですが、
環境差分値なんかはこれで渡してあげると、prod用stg用なんかを分けてあげられます。
// cdk_jsonに記載したcontext情報を取得
const envProps = app.node.tryGetContext(env);
試しに↑↑のenvProps
をconsole.logしておいて、
cdk synth
でテンプレートを出力してみます。
cdk synth -c env=test
うまくとれてますね。
一体何が生成されるのか
スタック作成前に、いかなるテンプレートを生み出そうとしてるのか確認しておきましょう。
先程の cdk synth
コマンドを使います。
ちょっとテンプレートを書き出してみましょう。
cdk synth -c env=test > template.yaml
割愛しますが、約500行くらいのテンプレートができあがってました。。
中身みてみると、クラスタのvpcを指定してないせいで、新規のvpcを作成してくれてる為、
SubnetやそれぞれのRouteTableなんかも全部用意する方向のようです。
この、よしなにやってくれる所、
根本を把握してないと痛い目みる事がありそうですね。
実際にテンプレートの差分とか見てないと、 ???となる事多そうです。
よくテンプレートをみてみる
出来上がったテンプレートをみてみると、
NATGateWayが2本できてました。
ん?と思って更にみると、
クラスタをPrivateSubnetに配置しようとしてくれているみたいです。
まあ基本確かにそうですよね。
更にはありがたい事に、
Public、PrivateのSubnetをマルチAZになるよう、2つのAZに作成してくれてました。
そしてPrivateSubnetそれぞにNATを作るという流れの模様。
はい、たしかに推奨設定で作ってくれました。
しかし今はテストしたいだけなのでTooマッチです。
NATGateWayは存在するだけでお金かかりますし。
今回はただのテスト開発ですぐ消つもりでいて、かつ、
一応twitterとslack叩くので外には出れてほしいという事で、
- クラスタはパブリックに配置
- マルチAZにしない
- もちろんNATGateWayつくらない
といった具体に変更しに行きます。
スタックの修正
最終版はこちら
import {Stack, StackProps, Fn, Aws, aws_ec2} from 'aws-cdk-lib';
import {Construct} from 'constructs';
import {SubnetType,Vpc} from "aws-cdk-lib/aws-ec2";
import {Cluster, ContainerImage, FargatePlatformVersion, LogDriver} from "aws-cdk-lib/aws-ecs";
import {ScheduledFargateTask} from "aws-cdk-lib/aws-ecs-patterns";
import {Repository} from "aws-cdk-lib/aws-ecr";
import {Schedule} from "aws-cdk-lib/aws-events";
export interface SearchTweetStackProps extends StackProps {
bearerToken: string;
searchQuery: string;
slackToken: string;
slackChannel: string;
}
export class SearchTweetStack extends Stack {
constructor(scope: Construct, id: string, props: SearchTweetStackProps) {
super(scope, id, props);
// dockerイメージの配置場所を作る
const ecrRepository = new Repository(this, 'searchTweetRepository', {
repositoryName: "search-tweet-repo",
imageScanOnPush: true
});
// クラスタを配置するVPCを作る
const vpc = new Vpc(this, 'searchTweetVpc', {
natGateways: 0, // natGatewayなし
maxAzs: 1, // シングルAZ
subnetConfiguration: [ // publicのサブネットを一つ
{
cidrMask: 28,
name: 'public',
subnetType: SubnetType.PUBLIC,
},
],
})
// ECSのクラスタを作る
const cluster = new Cluster(this, 'SearchTweetECSCluster', {
vpc // vpcを指定
});
// タスクスケジュールを組む
const scheduledTask = new ScheduledFargateTask(this, 'SearchTweetScheduledTask', {
cluster: cluster,
platformVersion: FargatePlatformVersion.LATEST,
schedule: Schedule.expression('cron(0/10 * * * ? *)'),
subnetSelection: {subnetType: SubnetType.PUBLIC}, // publicにサブネットに配置するように修正
scheduledFargateTaskImageOptions: {
image: ContainerImage.fromEcrRepository(ecrRepository, 'latest'),
memoryLimitMiB: 512,
cpu: 256,
logDriver: LogDriver.awsLogs({streamPrefix: 'ScheduledTaskLogs'}),
environment: {
BEARER_TOKEN: props.bearerToken,
SEARCH_QUERY: props.searchQuery,
SLACK_TOKEN: props.slackToken,
SLACK_CHANNEL: props.slackChannel,
},
}
});
}
}
差分的は以下で、初期値だった物を明示的に指定しているのみです
@@ -24,15 +24,30 @@ export class SearchTweetStack extends Stack {
imageScanOnPush: true
});
+ // クラスタを配置するVPCを作る
+ const vpc = new Vpc(this, 'searchTweetVpc', {
+ natGateways: 0, // natGatewayなし
+ maxAzs: 1, // シングルAZ
+ subnetConfiguration: [ // publicのサブネットを一つ
+ {
+ cidrMask: 28,
+ name: 'public',
+ subnetType: SubnetType.PUBLIC,
+ },
+ ],
+ })
+
// ECSのクラスタを作る
- const cluster = new Cluster(this, 'SearchTweetECSCluster', {});
+ const cluster = new Cluster(this, 'SearchTweetECSCluster', {
+ vpc // vpcを指定
+ });
// タスクスケジュールを組む
const scheduledTask = new ScheduledFargateTask(this, 'SearchTweetScheduledTask', {
cluster: cluster,
platformVersion: FargatePlatformVersion.LATEST,
schedule: Schedule.expression('cron(0/10 * * * ? *)'),
+ subnetSelection: {subnetType: SubnetType.PUBLIC}, // publicにサブネットに配置するように修正
scheduledFargateTaskImageOptions: {
image: ContainerImage.fromEcrRepository(ecrRepository, 'latest'),
memoryLimitMiB: 512,
再度テンプレートを書き出してみましょう。
cdk synth -c env=test > template2.yaml
500行くらいが 300行くらいになりました。
NatGatewayもいません
これで安心。
いざ、デプロイ!!
さてさて、いよいよデプロイします。
cdk deploy -c env=test
ホントに作るよ?? と、もう一度聞いてくれますので、
「よろしくおねがいしまーす!!」
と、大きい声で叫びながら y
を押します。
IAMやSGなんかの差分を改めて出してくれます。
数分くらいで出来上がりました。
これで準備は完了と行きたい所ですが、
lib/cdk-stack.ts
のScheduledFargateTask
には、search-tweet-repo
の名前で作ったECRのlatest
タグがついたimageを使ってね。と伝えてあります。
そうです、イメージ配置してませんね。
のでimage作ってECRにpushします。
ECRにタスクが使うimageをアップする
ホントにできてるかまずはECRをみてみましょう。
居ました。
タスク定義の方もみてみましょう。
はいはい確かにこのレポジトリみてますね。
このままでは、
タスクが走りだした時、ガチギレされる事間違いないです。
のでビルドしてpushしておきます。
# appまで移動
cd app
# アカウントID取得して
AWS_ACCOUNT_ID=$(aws sts get-caller-identity | jq -r .Account)
# Loginして
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com
# ビルドして
docker build -t search-tweet-repo:latest .
# タグ付けして
docker tag search-tweet-repo:latest ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/search-tweet-repo:latest
# pushする
docker push ${AWS_ACCOUNT_ID}.dkr.ecr.ap-northeast-1.amazonaws.com/search-tweet-repo:latest
ちなみに、ビルドする際、お使いのPCがmacでかつApple Silicon(M1)だった場合、
タスクの起動で失敗しますので、 --platform amd64
を指定してください。
生成されたimageのCPUのアーキテクチャが異なってしまうので、
amd64のCPUで動くようにしてあげる必要があります。
まったくM1のくせに笑えない冗談です。
pushできたか確認します。
aws ecr describe-images --repository-name search-tweet-repo | jq .
> "repositoryName": "search-tweet-repo",
> "imageDigest": >"sha256:71cba306c79ffd148d44b331e6a0e11cee3daf7304e098647dbf71f913399410",
> "imageTags": [
> "latest"
> ],
ふむふむできていますね。
そうこうしている内に、タスクが実行された模様です。
タスクのログにもconsole.logした値が入ってました。めでたしめでたし。
気が済んだので消します
cd cdk
cdk destroy
消すよ...? と聞かれるので、
空から降ってきた女の子を探し、そっと手を重ねてから
キリッとした顔をして 「バルス」 と言い、
y
を押します。
まとめ
やりたかったのは、何かしらのコマンド機構をECSで定期実行してみるという事でした。
まだサーバのcrontabで動かしてたりするものがあれば、
App部分をそのままimageにしてしまえば、簡単にcronをサーバから切り出せます。
それを実感したかった次第です。
fargateで組んでしまえば、立ち上がりはすぐですので、
長ーーーい処理でない限り、起動時間はごくわずかなはずです。
1時間あたりの起動時間の料金が0.01USDとかなので、
ほとんどお金に跳ねて来ないかなと。
次にcdkですが、めちゃ便利ですね!
デフォで推奨設定を入れてくれる所が光ってます。
ただ便利ですけどまずはCloudFormationを素で書けるくらいになってから使う方がよいですね。
ORMで作ったSQLは吐き出して確認しましょう、というのと同じノリで、
きっとcdk使うときは、 cdk synth
を叩きまくるのでしょう。
最後に
今回、機密情報(トークンとか)を
cdk.json ⇨ タスク定義 ⇨ タスク ⇨ コンテナ ⇨ app の経路で渡しちゃってますのでご注意を。
タスク定義みればconsoleから丸見えです。SecretsManager絡めましょう。
あと振り返ると、tsファイルをトランスパイルしないで最後まで行ってしまいました。。
dockerfile内でjsにしてあげてコンテナは実行するのは node index.js
にしてあげるべきでした。
そしてずっと謎なのが、
twitterAPIのsearch/recent
エンドポイントで取得した検索結果と、
実際にwebの検索から取得した結果が異なるのです、、、
これがずっと解決せず。。。どなたか知ってる方いたら教えてください。
具体的には AWS 障害
とかで検索すると、
この方のtweetはAPIからしか取得できぬのです。
↓ ↓ ↓ ↓
アマゾンのクラウドAWS障害発生! 国内で広範囲に影響 GitHubなどでも障害発生! | 速報・事件事故まとめ https://t.co/sGZsltnMA9
— 橋本バッキャオ (@doragon3756) June 1, 2022
機械的に投稿してる模様なのでそういうのはwebやアプリでは弾いてるのか??
めっちゃ長くなった..
読んでくださった方はありがとうございます。
是非飲みに行きましょう。
参考文献