「認証情報が取れない」が再起動でしか直らない ― 古いAWS SDKのネガティブキャッシュ落とし穴
平和な午後でした。
突然 Datadog のアラートが鳴り出して、エラーログがどっと流れ始めたんです。本番の API が 500 を返しまくっている。画面を開いてもデータが出てこない。「え、なにこれ」と思っているうちに、Slack には「画面見れないんですけど」の声がぽつぽつ。
緊急対応の始まりです。チームで集まって調査開始。
まず疑うのは、いつものやつ。「直近で何かリリースした?」…してない。ここ数日デプロイはゼロでした。じゃあアプリのコードは無罪っぽい。
ログを見ると、AWS 系の呼び出しがぜんぶこのエラーで落ちています。
Couldn't find AWS credentials in environment, credentials file, or IAM role.「認証情報が取れない?IAM ロールが外れた?」「メタデータエンドポイントの不調とか?」。環境まわりを疑って手分けして調べるものの、設定は何も変わっていないし、決定打が出てこない。時間だけが過ぎていきます。
エラーは出続けている。とりあえず止血しないと、ということでECS のタスクを再起動しました。
…直りました。あっさり。
エラーはピタッと止まって、画面も元通り。ひとまず一件落着。でも、原因が分からないまま再起動で直ってしまったので、ものすごくモヤモヤするわけです。「なんで直ったの?」「これ、また起きるよね?」
このモヤモヤを放置できず、後日きっちり調べました。そしてたどり着いた犯人が、使っていた古い AWS SDK の設計の落とし穴でした。具体的には「1回の取得失敗を、プロセスが死ぬまで一生キャッシュする」という、なかなか厄介な挙動です。
この記事では、その調査の流れと、再現コード、これからどう直していくかを残しておきます。同じ落とし穴は他の言語・他のキャッシュ実装でも起こりうるので、Rust を使っていない方にも刺さるはずです。
どんな構成だったか
犯人の話に入る前に、ざっくり構成を共有させてください。
- Rust 製の HTTP API サーバー。S3 / DynamoDB 相当のマネージドサービスを叩く
- ECS 上で複数タスク(インスタンス)が動く
- 認証情報は固定キーじゃなく、実行ロールの一時クレデンシャル。コンテナのメタデータ用エンドポイント経由で配られ、数時間ごとにローテーションされる
- AWS SDK クライアントは起動時に1回だけ作って、プロセスが生きてる間ずっと使い回す(
Arcでラップ)
ここでジワっと効いてくるのが、最後の2つの組み合わせです。
長寿命の使い回しクライアント と 定期的なクレデンシャルのローテーション。この2つが噛み合うと、「更新が1回失敗しただけで全リクエストが巻き添えになる」という事故が起こりえます。これが今回の伏線でした。
落ち着いてから、手がかりを並べてみる
復旧後、頭を冷やして観測できた事実を並べてみると、こんな感じでした。
- エラーが出ていたのは、複数タスクのうち一部だけ(だからエラー率が 100% じゃなく中途半端だった)
- エラー本文はぜんぶ同じ
Couldn't find AWS credentials ... - 出始めてから数時間ずっと止まらなかった
- 再起動したら即座に直った
- 直近のリリースはなし
「一部だけ」「数時間」「再起動で直る」。この3点セットがずっと引っかかっていました。コードを変えてないのに一部のタスクだけ壊れて、しかも再起動でしか直らない。アプリのバグというより、プロセスの中に変な状態が居座っている気配がします。
で、SDK のソースを読みにいったら、当たりでした。
原因はSDKの設計にあった
使っていたのは、開発停止済みの古い SDK rusoto 0.47 です。そのクレデンシャル自動更新ラッパ AutoRefreshingProvider の実装を読んで、ようやく腑に落ちました。
中身を要点だけ抜き出すと、こうなっています。
// 状態: Option<Result<AwsCredentials, CredentialsError>>
// None … キャッシュ無し(初期 / 無効化直後)
// Some(Ok(creds)) … 認証情報をキャッシュ中
// Some(Err(e)) … エラーをキャッシュ中 ← これがハマりポイント
async fn credentials(&self) -> Result<AwsCredentials, CredentialsError> {
loop {
let mut guard = self.current_credentials.lock().await;
match guard.as_ref() {
None => {
let res = self.credentials_provider.credentials().await; // 実フェッチ
*guard = Some(res); // ← Err でもそのまま保存
}
Some(Err(e)) => return Err(e.clone()), // ← 以後ずっとコレを返す。再取得しない
Some(Ok(creds)) => {
if creds.credentials_are_expired() {
*guard = None; // 期限切れ(Ok)だけ None に戻して再取得
} else {
return Ok(creds.clone());
}
}
}
}
}"出典:
rusoto/credential/src/lib.rs#L278-L296(rusoto 0.47.0, OSS / MIT・Apache-2.0)
ポイントは2つです。
- 実際に取得しにいくのは
Noneのときだけ。タイマーもバックグラウンド更新もなく、完全に遅延(lazy)。 - 取得が失敗すると
Some(Err)として保存され、次回以降はreturn Err(e.clone())でキャッシュ済みのエラーを返すだけ。Some(Err)をNoneに戻すコードが、どこにもありません。
Some(Ok) は期限切れになれば None に戻れる(=再取得の道がある)のに、Some(Err) からは戻る道がない。この非対称が事故の核心でした。
図にするとはっきりします。
つまり、クレデンシャルのローテーション更新がたった一度でも失敗すると(メタデータエンドポイントへの一時的な timeout や接続エラーなど)、そのプロバイダはプロセスが生きてる限り永久に認証エラーを返し続ける。クリアする唯一の方法は、プロバイダの作り直し=プロセスの再起動だけ。
「一部だけ」「数時間」「再起動で直る」が、これで全部つながりました。
再現コードで確かめる
「コードを読むとそう見える」だけだと、ちょっと不安ですよね。なので実際に手元で再現しました。実 AWS には一切つなぎません。本番で起きたのと同じ流れ、つまり 最初は成功 → 有効期限切れのリフレッシュで一度だけ失敗 → そのあと復旧しても poison を内側プロバイダの戻り値だけで再現します。
ここで大事な前提を1つ。AutoRefreshingProvider が内側プロバイダを呼ぶのは、キャッシュが None のときだけ です。None になるのは「起動直後」と「成功キャッシュが期限切れになった直後」の2回だけ。有効期限内は、成功キャッシュをそのまま返して内側は呼びません。
なので「1回目成功・2回目失敗・3回目以降成功」を素直に並べても、内側が呼ばれるタイミングは外側の呼び出し回数とは一致しないんです。試行回数ではなく期限切れ後のリフレッシュが引き金になります。
[dependencies]
rusoto_credential = "0.47.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
async-trait = "0.1"
chrono = "0.4"use async_trait::async_trait;
use chrono::{Duration as ChronoDuration, Utc};
use rusoto_credential::{
AutoRefreshingProvider, AwsCredentials, CredentialsError, ProvideAwsCredentials,
};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
struct RotatingProvider {
calls: AtomicUsize,
}
#[async_trait]
impl ProvideAwsCredentials for RotatingProvider {
async fn credentials(&self) -> Result<AwsCredentials, CredentialsError> {
let n = self.calls.fetch_add(1, Ordering::SeqCst);
match n {
// 1回目: 成功。有効期限は「今から21秒後」。
// rusoto は残り20秒を切ると期限切れ扱いなので、21秒先=バッファの1秒外。
// 発行直後の attempt 1 ではまだ有効(=OK)になる。
0 => {
println!(" [inner] call #{n} -> Ok (有効期限 +21s)");
Ok(AwsCredentials::new(
"AKIAEXAMPLE", "secret", None,
Some(Utc::now() + ChronoDuration::seconds(21)),
))
}
// 2回目(期限切れ後のリフレッシュ): 失敗
1 => {
println!(" [inner] call #{n} -> Err (リフレッシュ失敗)");
Err(CredentialsError::new("refresh failed"))
}
// 3回目以降: 成功(もう復旧している)… が、ここには到達しない
_ => {
println!(" [inner] call #{n} -> Ok (復旧済み)");
Ok(AwsCredentials::new(
"AKIAEXAMPLE", "secret", None,
Some(Utc::now() + ChronoDuration::hours(1)),
))
}
}
}
}あとは同じ src/main.rs の続きです。これを AutoRefreshingProvider で包み、間に少しスリープを挟みながら5回クレデンシャルを要求します(期限切れを跨がせるため)。
#[tokio::main]
async fn main() {
let provider =
AutoRefreshingProvider::new(RotatingProvider { calls: AtomicUsize::new(0) }).unwrap();
for attempt in 1..=5 {
match provider.credentials().await {
Ok(_) => println!("attempt {attempt}: OK"),
Err(e) => println!("attempt {attempt}: ERR ({e})"),
}
// 有効期限の20秒バッファを跨いでリフレッシュを発生させる
tokio::time::sleep(Duration::from_secs(2)).await;
}
}cargo run するとこうなります。
[inner] call #0 -> Ok (有効期限 +21s)
attempt 1: OK
[inner] call #1 -> Err (リフレッシュ失敗)
attempt 2: ERR (refresh failed)
attempt 3: ERR (refresh failed)
attempt 4: ERR (refresh failed)
attempt 5: ERR (refresh failed)「21秒」は待ち時間ではありません。 rusoto は「残り20秒を切ったら期限切れ扱い」(expires_at < now + 20秒)です。なので有効期限をバッファのちょうど1秒外=21秒先に置くと、発行直後の attempt 1 では「まだ有効(残り21秒)」で OK。その後 sleep(2秒) で残りが約19秒になり、20秒バッファに入る=期限切れ扱いになって、attempt 2 でリフレッシュ(=失敗)が走ります。実際に待つのは sleep の2秒だけ。20秒以下にすると発行直後に期限切れ扱いとなり、attempt 1 から失敗してしまうので「1秒だけ外」の21秒にしています。
注目してほしいのは、内側プロバイダの call #2(復旧済みの成功)が一度も呼ばれていない ことです。流れを追うとこうなっています。
| 外側の呼び出し | キャッシュ状態 | 内側の呼び出し | 結果 |
|---|---|---|---|
| attempt 1 | None → 取得 | call #0(成功) | OK。Some(Ok) を保存 |
| (期限切れまで) | Some(Ok) 有効 | 呼ばない | OK を返し続ける |
| attempt 2 | Some(Ok) 期限切れ → None → 取得 | call #1(失敗) | Some(Err) を保存 → ERR |
| attempt 3〜 | Some(Err) | 呼ばない | キャッシュ済み ERR を返し続ける |
最初はちゃんと動いていて、期限切れ後の最初のリフレッシュが失敗した瞬間に poison。あとはエンドポイントが復旧していても(=3回目以降は成功する状態でも)、内側を二度と呼ばないので永久にエラーです。本番で起きたことが、手元できれいに再現できました。
動かせる全コードはここに置いてあります → techarm/blog-code-examples
なぜ「一部だけ」「数時間」だったのか
症状の一つひとつが、この挙動でちゃんと説明できます。
| 観測 | 説明 |
|---|---|
| 一部のタスクだけ | ローテーション更新の一時失敗とタイミングが重なったタスクだけ poison。他は更新成功で正常継続 |
| 数時間ずっとエラー | poison 後は再試行せず、キャッシュ済み Err を返し続ける(自己回復しない) |
| 再起動で即解消 | 新プロセス=新プロバイダ(初期値 None)が正常に取得し直す |
| S3 も DynamoDB も全滅 | クライアントごとに独立したプロバイダがいて、それぞれ同じ時間帯の更新で poison された |
ちなみに、最初の一時失敗そのものの原因はログに残っていませんでした。フォールバックのチェーン実装が各取得元を if let Ok(creds) = ... で受けていて、個別のエラーを握りつぶして総括メッセージだけ返していたからです。握りつぶしはデバッグを難しくするという、地味だけど痛い学びでした。
ここで終わらなかった理由 ― 浅いヘルスチェック
正直、SDK の poison だけなら「運が悪いタスクが1つ」で済んだはずなんです。これを数時間の障害にまで引き伸ばしたのは、別の問題でした。
ヘルスチェックが浅かったんですよね。
- コンテナ自体のヘルスチェックは未設定
- ロードバランサのヘルスチェックは、固定で 200 を返すだけのエンドポイントを叩いていた(依存先の AWS には一切触れない)
結果、認証が壊れたタスクもヘルスチェックは通り続け、ロードバランサのローテーションに残ったまま、実リクエストには 500 を返し続けました。基盤側は「不健全」と判定できず、自動で入れ替わってくれなかったわけです。
200 を返すだけのヘルスチェックは「プロセスが生きてるか」しか見ていません。「依存先まで含めて、ちゃんと仕事ができる状態か」までは分からないんです。
ヘルスチェックは「プロセスの生存確認」じゃなく「仕事ができる状態かの確認」であるべきでした。重要な依存(DB / 外部 API / 認証)に軽く触れる deep health check なら、「生きてるけど仕事はできない」ゾンビ状態を検知して、自動で切り離せます。
ただし、依存先に負荷をかけすぎない、タイムアウトは短く、みたいなバランスは要ります。深いヘルスチェックとオートスケールを安易に組み合わせると、依存先が一瞬詰まっただけで健全なインスタンスまで道連れに入れ替わる、という別の事故もあるので、そこは設計が必要です。
今回の学び
rusoto を知らなくても役立つところだけ、さらっとまとめておきます。
- 失敗のキャッシュは慎重に。 「失敗結果」をキャッシュするなら、TTL・再試行・バックオフを必ずセットで設計する。
Okは期限管理するのにErrは永久保持、という非対称は事故のもとです。 - 浅いヘルスチェックの罠。 200 を返すだけのチェックは、依存が壊れたゾンビを健全扱いしてしまう。
- 長寿命の使い回しクライアント × 一時クレデンシャル。 起動時に作って使い回すクライアントは、内部プロバイダの更新失敗が全リクエストに波及しえます。更新の堅牢さ(リトライ・自己回復)が要件になる。
- EOL 依存を放置するリスク。 この問題はメンテ終了済みのライブラリのもので、もう直りません。「動いてるから」で放置すると、踏んだときに自分でパッチを当てるしかなくなります。
rusoto は 2021 年に開発停止し、現在は deprecated です。公式も後継の aws-sdk-rust への移行を推奨しています。
これからどう直すか
今回の障害、振り返ると2つの問題が重なっていました。SDK の poison と、それを検知できなかった浅いヘルスチェック。なので対策も2方向です。
▶応急処置(対応済み)
その場は ECS タスクの再起動で復旧しました。poison したプロバイダは新プロセスで作り直されるので、これでエラーは止まります。
ただ、これはあくまで対症療法です。原因の SDK が残っている限り、また踏む可能性があります。なので、ここからが本番。
▶今後の改善課題
- ヘルスチェックを deep health check に改善する。 今は 200 を返すだけなので、依存先(認証含む)に軽く触れる形にして、ゾンビ化したタスクを自動で切り離せるようにします。これがあれば、今回も人間が気づく前に基盤側が勝手に入れ替えてくれていたはずなんですよね。
- aws-sdk-rust への移行計画を立てて、段階的に移していく。 いきなり全部は無理なので、影響の小さいところから少しずつ。現行 SDK のクレデンシャルキャッシュは設計思想がそもそも違って、失敗はキャッシュしない・リトライとバックオフを持つ・期限内は古いクレデンシャルで動かしつつ裏で更新を試みる。今回の「1回失敗で永久 poison」のような問題が、構造的に起きにくくなっています。
すぐに全移行できなくても、AWS 呼び出しを「認証エラー時にプロバイダ/クライアントを作り直すラッパ」で包めば、プロセスを殺さずに自己回復させられます。移行までの延命策として。
まとめ
今回の障害を一言でいうと、「1回の失敗を一生キャッシュするクレデンシャル層」と「それを検知できない浅いヘルスチェック」の合わせ技でした。応急は再起動でしのぎ、根本対応は deep health check と aws-sdk-rust への段階移行で進めています。
再起動で直ったときの「なんで直ったの?」というモヤモヤ、放置しなくてよかったです。「失敗をキャッシュするなら、必ず戻り道(TTL・リトライ)を用意する」。今回いちばん刻まれた教訓でした。あなたのコードやライブラリにも、こっそり似た非対称が潜んでいないか、一度のぞいてみてください。
この記事はいかがでしたか?
もしこの記事が参考になりましたら、
高評価をいただけると大変嬉しいです!
皆様からの応援が励みになります。ありがとうございます! ✨