障害切り分けの一例~ビルドパイプラインがなんか壊れた~

3年くらい前に書いたまま放置していたリバイバル記事です。

## 導入

一か月ぐらい前、CircleCIでtflintによりTerraformの確認を行っていた環境が壊れて、
急にパスしなくなったので理由を調べました。

謎が解けてみれば非常に単純ではありましたが、
その過程が意外と面白かったので、記録に残したいと思います。

最初にネタバラシをすると、CircleCIもtflintも障害の理由とはまったく関係ありませんでした。
理由と対応方法だけ読みたい方は一番最後をご覧ください。

## 対象のCircleCIのジョブ(障害発生版)

```yaml
jobs:
  terraform-lint-check:
    docker:
      - image: hashicorp/terraform:1.3.0
        environment:
          TF_WORKSPACE: dev
    working_directory: ~/go/src/github.com/foo/bar/infrastructure/terraform
    steps:
      - checkout:
          path: ~/go/src/github.com/foo/bar
      - run:
          name: Terraform init
          command: |
            terraform init -backend=false
      - run:
          name: Install tflint
          command: |
            apk update --no-cache && apk add --no-cache unzip curl bash
            curl -s https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash
      - run:
          name: Lint check
          command: |
            tflint --init
            tflint
```

## 障害が発生した経緯

チームのメンバーの一人が朝方、CircleCIのtflintの部分でエラーが発生するのだけど...と
Slackに投稿したのが最初のインシデント報告。

続くスレッドに張られたログを見ると
確かにCircleCIが48のエラーコードで失敗していることがわかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2631955/560e8624-7e40-bcae-f3f4-7beef1cbd616.png)

この段階では当然原因はわかりませんでした。

## Slackでコメントを読んでぱっと浮かんだこと

まっさきに検討に上がったのは `tflint` がサイレント変更されたのではないか？という疑惑でした(完全に濡れ衣だと後でわかります)
インストールのスクリプトでバージョンを固定していないことも疑惑に拍車をかけ、下記の切り分けを行いました

* tflintで関連するissueは上がっているか？
  * 問題なし
* tflintをローカル環境(WSL)でインストールしたら？
  * 問題なし
* tflintのバージョンを明示的に指定したら？
  * いくつかやってどれもCircleCIでNG

## 「ローカル環境」という言葉で別の再現方法を思いつく

わちゃわちゃしだしたスレッドを見て、この辺で再現方法違ってたんじゃね？と気づきました。

ローカル環境というけど、CircleCIなんだからコンテナ環境で検証する必要があるのでは？と。

ジョブ内容を見ていると `hashicorp/terraform:1.3.0` を使っていることがわかります。
ローカル環境で動かしてみました。

```shell
$ docker run -it --rm  hashicorp/terraform:1.3.0 /bin/sh
Terraform has no command named "/bin/sh".

To see all of Terraform's top-level commands, run:
  terraform -help
```

普通に通りません。

https://github.com/hashicorp/terraform/blob/main/Dockerfile

`hashicorp/terraform` のDockerfileを見ると次のことがわかります。
* ベースイメージは `golang:alpine` イメージを使用している
  * shは動くことがわかります 
* エントリポイントは `/bin/terraform"`
  * 普通にコンテナを実行すると `/bin/terraform` コマンドにパラメータが渡されます

`terraform` に `/bin/sh` という引数はないのでエラーになったのでした。
こういう時は動的にエントリポイントを書換えてあげる必要があります。

```sh
$ docker run -it --rm --entrypoint=/bin/sh hashicorp/terraform:1.3.0
/ # 
```

コンテナ内に入れました。後は同じコマンドを試すだけです。

```sh
/ # apk update --no-cache && apk add --no-cache unzip curl bash
fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/main/x86_64/APKINDEX.tar.gz
fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/community/x86_64/APKINDEX.tar.gz
v3.16.5-42-g1ce1b018120 [https://dl-cdn.alpinelinux.org/alpine/v3.16/main]
v3.16.5-41-geeab6d0b981 [https://dl-cdn.alpinelinux.org/alpine/v3.16/community]
OK: 17046 distinct packages available
fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/main/x86_64/APKINDEX.tar.gz
fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/community/x86_64/APKINDEX.tar.gz
(1/4) Installing readline (8.1.2-r0)
(2/4) Installing bash (5.1.16-r2)
Executing bash-5.1.16-r2.post-install
(3/4) Installing curl (8.0.1-r0)
(4/4) Installing unzip (6.0-r9)
Executing busybox-1.35.0-r17.trigger
OK: 28 MiB in 35 packages

/ # curl -s https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash
(...すんっ)
```

既存のCircleCIログでは、この後でtflintのインストールログが走ります。
(...すんっ)のところで、確かにインストールが止まってしまったようです。

ただ、これだけではCircleCIで起きた事象と同一かまではわかりません。
内容を細かく見るためにスクリプトをwgetで落として実行してみることにしました。

```shell
/ # wget https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh
Connecting to raw.githubusercontent.com (185.199.108.133:443)
saving to 'install_linux.sh'
install_linux.sh     100% |*************************************************************************************|  2980  0:00:00 ETA
'install_linux.sh' saved

/ # bash install_linux.sh
arch=amd64
os=linux_amd64


====================================================
Looking up the latest version ...
curl: (48) An unknown option was passed in to libcurl
Downloading TFLint
curl: (48) An unknown option was passed in to libcurl
Downloaded successfully


====================================================
Unpacking /tmp/tflint.XXXXbjnDBi/tflint.zip ...
unzip:  cannot find or open /tmp/tflint.XXXXbjnDBi/tflint.zip, /tmp/tflint.XXXXbjnDBi/tflint.zip.zip or /tmp/tflint.XXXXbjnDBi/tflint.zip.ZIP.
Installing /tmp/tflint.XXXXbjnDBi/tflint to /usr/local/bin/ ...
install: can't stat '/tmp/tflint.XXXXbjnDBi/tflint': No such file or directory
Failed to install tflint
```

途中で `curl: (48) An unknown option was passed in to libcurl` というエラーログが出ていることがわかります。
CircleCIが終了したあの謎のエラーコード(48)はcurlが原因で間違いないでしょう。

こうして無事に再現手順を確立しました。

## なんで失敗したのか？原因を調べる

素直に読み解くと、libcurlに謎のオプションが渡されていることが原因ですが、
先ほどのチームメンバーの報告で直近でtflintのインストールスクリプトは修正されていないので、
急にcurlのパラメータが変わったわけではなさそうです。

脈はなさそうと思いつつ、一応ChatGPT先生にはcurlのオプションを聞きました。

```text
Q. 下記のcurlコマンドのオプションの意味を教えて
curl --fail -sS -L -o "${download_zip}" "https://github.com/terraform-linters/tflint/releases/download/${version}/tflint_${os}.zip"

A. 
以下は、使用されているcurlコマンドの各オプションの意味です。

-f, --fail: サーバーからHTTPステータスコードがエラーを示す場合に、ダウンロードの実行を中止します。たとえば、HTTP 404エラーが返されると、ダウンロードは失敗し、curlはエラーコードを返します。
-s, --silent: curlがダウンロードの詳細を表示しないようにします。通常、ダウンロードの詳細は標準エラー出力に出力されますが、このオプションを使用すると表示されなくなります。
-S, --show-error: エラーが発生した場合に、エラーメッセージを表示します。
-L, --location: サーバーがリダイレクトを行っている場合に、リダイレクト先にファイルをダウンロードします。
-o, --output: ダウンロードしたデータをファイルに出力します。${download_zip}は、ダウンロードしたデータを保存するための変数です。
"https://github.com/terraform-linters/tflint/releases/download/${version}/tflint_${os}.zip": ダウンロードするファイルのURLです。${version}と${os}は変数であり、それぞれダウンロードするファイルのバージョンとオペレーティングシステムを指定します。

https://curl.se/docs/manpage.html
```

エラーログを改めてみると、 `An unknown option was passed in to libcurl` とあります。

...curlとlibcurlって一緒じゃないの？
素朴な疑問が湧きました。

[`everything cURL`](https://everything.curl.dev/project/does)という公式ドキュメントには、
cURLというプロジェクトには2つの目的があると記されています。

* curl: コマンドラインツール
* libcurl: CAPIを提供する転送用ライブラリ

いつも実行しているcURLコマンドも、じつは二部構成に分かれており、
転送のコアロジックはlibcurlとして、curl以外でも使えるようにライブラリとして外出しされています。

そのように整理してみると、このcurl/libcurlのIF部分が非常に怪しいですね。

[curl 関係のエラー](https://hydrocoast.jp/index.php?Ubuntu/curl%E9%96%A2%E4%BF%82%E3%81%AE%E3%82%A8%E3%83%A9%E3%83%BC)にて先人が同じエラーに遭遇していたので、コマンドを借りてきます。

```
# 大文字のVパラメータではcurlとlibcurlのバージョンを表示する
/ # curl -V
curl 8.0.1 (x86_64-alpine-linux-musl) libcurl/7.83.1 OpenSSL/1.1.1q zlib/1.2.12 brotli/1.0.9 nghttp2/1.47.0
Release-Date: 2023-03-20
Protocols: dict file ftp ftps gopher gophers http https imap imaps mqtt pop3 pop3s rtsp smb smbs smtp smtps telnet tftp
Features: alt-svc AsynchDNS brotli HSTS HTTP2 HTTPS-proxy IPv6 Largefile libz NTLM NTLM_WB SSL TLS-SRP UnixSockets
WARNING: curl and libcurl versions do not match. Functionality may be affected.
```

curlのバージョンが8.0.1に対して、libcurlのバージョンが7.83.1なのでバージョンが食い違っていることがわかりました。

## 解決するまで

cURLが動かないのはバージョンの食い違いが原因だろうと突き止めたので、
解決策としてはlibcurlのバージョンを合わせれば良いだけです。

どうやってlibcurlを入れればいいのでしょうか？

ここで、ベースイメージが `golang:alpine` であることを思い出してください。

たとえば、Ubuntuでは `apt install libcurl4-openssl-dev` コマンドにより、
opensslをTLSライブラリとして使用したlibcurl[^1] を入れれば、libcurlが入ります。

しかし、Alpine Linuxでは `apt` パッケージマネージャーは動きません。

有志の方がAlpine Linux用のパッケージマネージャーAPKと(Ubuntu等で使用される)APTの[比較表](https://github.com/dotnet/core/issues/226#issue-165738795)を出していただいてるので
それを参考に `libcurl4-openssl-dev` にあたる `curl-dev` をインストールします。

```
/ # apk update --no-cache && apk add --no-cache unzip curl bash curl-dev #←最後にこれを追加
fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/main/x86_64/APKINDEX.tar.gz
fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/community/x86_64/APKINDEX.tar.gz
v3.16.5-42-g1ce1b018120 [https://dl-cdn.alpinelinux.org/alpine/v3.16/main]
v3.16.5-41-geeab6d0b981 [https://dl-cdn.alpinelinux.org/alpine/v3.16/community]
OK: 17046 distinct packages available
fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/main/x86_64/APKINDEX.tar.gz
fetch https://dl-cdn.alpinelinux.org/alpine/v3.16/community/x86_64/APKINDEX.tar.gz
(1/13) Upgrading libcrypto1.1 (1.1.1q-r0 -> 1.1.1t-r2)
(2/13) Upgrading libssl1.1 (1.1.1q-r0 -> 1.1.1t-r2)
(3/13) Installing readline (8.1.2-r0)
(4/13) Installing bash (5.1.16-r2)
Executing bash-5.1.16-r2.post-install
(5/13) Upgrading libcurl (7.83.1-r3 -> 8.0.1-r0)
(6/13) Installing curl (8.0.1-r0)
(7/13) Installing pkgconf (1.8.1-r0)
(8/13) Installing openssl-dev (1.1.1t-r2)
(9/13) Installing nghttp2-dev (1.47.0-r0)
(10/13) Installing zlib-dev (1.2.12-r3)
(11/13) Installing brotli-dev (1.0.9-r6)
(12/13) Installing curl-dev (8.0.1-r0)
(13/13) Installing unzip (6.0-r9)
Executing busybox-1.35.0-r17.trigger
Executing ca-certificates-20220614-r0.trigger
OK: 31 MiB in 41 packages
/ # curl -V
curl 8.0.1 (x86_64-alpine-linux-musl) libcurl/8.0.1 OpenSSL/1.1.1t zlib/1.2.12 brotli/1.0.9 nghttp2/1.47.0
Release-Date: 2023-03-20
Protocols: dict file ftp ftps gopher gophers http https imap imaps mqtt pop3 pop3s rtsp smb smbs smtp smtps telnet tftp
Features: alt-svc AsynchDNS brotli HSTS HTTP2 HTTPS-proxy IPv6 Largefile libz NTLM NTLM_WB SSL threadsafe TLS-SRP UnixSockets
/ #
```
無事解決しました。


:::note info
## 解決してもなお残る疑問

今回の件は、curlコマンドのクライアントとライブラリのバージョンが食い違ったことによって発生しましたが、そもそもなぜライブラリだけ、`golang:alpine` に入ってたのでしょうか？推測は成り立ちますがよくわからないので、alpineの成り立ちについて詳しい方がいらっしゃったら教えてください
:::

## 障害切り分けと解決策を振り返って

分かってしまえばなんてことない障害ですが、それでも色んなスキルや技術知識がちょっとずつ要求されることに気づかされます。

* Dockerクライアントのオプション
* curlの構成
* APTパッケージマネージャーの違い

こういう積み重ねで自分生きてるなーって気づかされます。


[^1]: 他にも様々な種類のTLSライブラリに対応しています。詳細は https://everything.curl.dev/build/tls を参照してください