Dotnet CoreプロジェクトでプライベートNuGetフィードを使ってセキュアなDockerイメージを作る

# はじめに
タイトルに書いた通りの記事です。パブリックなNuGetフィードだけを使った場合は特に気にすることもなくさっくりDockerイメージを作れるのですが、プライベートfeedを使おうとした途端に厄介なことがでてきます。あまりそんなことが必要な人はいないかも知れませんが、自分への備忘録として残しておきます。

# TL; DR
主なハマりポイントと解決策を先に列挙しておきます。

### ハマりポイント
- Dockerfile内の`dotnet restore`にプライベートフィード(Azure DevOpsなどに置いてある)へのクレデンシャル情報を渡す必要がある
- クレデンシャルを平文でDockerfileに直接書くのはセキュリティ的によろしくない
- `--build-args`でクレデンシャルを引数として渡すのもダメ(理由は後述)

### 解決策
- パーソナルアクセストークン (PAT) を発行してクレデンシャルとして利用する。手動のサインインが不要になる
- `VSS_NUGET_EXTERNAL_FEED_ENDPOINTS`環境変数を使ってクレデンシャルを`dotnet restore`に渡す
- Dockerのsecret volume機能を使う

# 具体的な手順
### 前提
Azure DevOpsでプライベートfeedを使う([公式ドキュメント](https://docs.microsoft.com/en-us/azure/devops/artifacts/get-started-nuget?view=azure-devops&tabs=windows))ことを想定して記事を書いてます。

### `dotnet restore`でデフォルト以外のNuGetフィードを使う
この[StackOverflow記事](https://stackoverflow.com/questions/57362453/artifacts-credprovider-and-vss-nuget-external-feed-endpoints-with-docker)が参考になります。平たく言うと`VSS_NUGET_EXTERNAL_FEED_ENDPOINTS`という環境変数にJSONの形でフィードのURLとクレデンシャルを渡してやることで、`dotnet restore`がデフォルト以外のフィード (`-s`オプションで指定) からパッケージを持ってきてくれます。こんな感じです。

```Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
ENV ASPNETCORE_URLS="https://+;http://+"

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
# Auth with private feed
WORKDIR /tool
RUN wget https://raw.githubusercontent.com/Microsoft/artifacts-credprovider/master/helpers/installcredprovider.sh \
   && chmod +x installcredprovider.sh \
   && ./installcredprovider.sh

WORKDIR /src
COPY ["./MyProject.csproj", "MyProject/"]

ARG FEED=https://pkgs.dev.azure.com/...../nuget/v3/index.json
RUN VSS_NUGET_EXTERNAL_FEED_ENDPOINTS='{"endpointCredentials":[{"endpoint":"'${FEED}'","username":"name","password":"パスワード"}]}' dotnet restore -s ${FEED} "MyProject/MyProject.csproj"
```
10行目で credential provider をインストールしたあと、最後の行で`dotnet restore`を呼び出しています。

しかし、このままだとログイン名とパスワードをべたに`Dockerfile`中に埋め込むことになってしまいます。それは避けなければならないですね。かといって`docker build`の最中に手動でログインすることは不可能です。

### Azure DevOpsでパーソナルアクセストークン (PAT) を発行する
というわけでパーソナルアクセストークン(PAT)を取得しましょう。GitHubにも同様の機能があるので、ご存じの方はすぐ理解してもらえると思います。[公式ドキュメントはここです](https://docs.microsoft.com/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=Windows)。公式ドキュメントはPATの操作について一通り網羅しているので長いですが、序盤の"Create a PAT"だけ読めばPATを作れます。作ったら忘れずコピーしておきましょう。

おさらいですが、PATが必要な理由は以下の2点です。
1. Azure DevOpsのプライベート NuGet feed からパッケージをダウンロードするためにはアクセス権が必要
1. Dockerfile内で`RUN dotnet restore`したいので、手動でログインすることはできない

ただし公式ドキュメントにもあるように**PATはパスワードとほぼ同等**なので、これも安易にそのままDockerfileに埋め込んでgit commitするわけにはいきません。このため必然的に`docker build`実行時に外からPATを渡すことになります。

### PATを`docker build`に渡す
`docker build`に実行時パラメータを渡すための一般的な方法は`--build-args`を使うことですが、**パスワードを渡すのに使うと危険です**。どうしてかというと、`docker history`コマンドで丸わかりだからです。[ここのブログ](https://medium.com/marionete/pass-secure-information-for-building-docker-images-8adeafe08355)でくわしく説明されています。

(余談ですが、`go build`でパッケージをgitリポジトリから持ってくる場合も同様の問題が起こります。[このブログ](https://www.dudley.codes/posts/2020.12.28-dockerfile-build-args-exposed/)で説明されています。)

### secret volume機能を使う
解決策は上記のブログに書いてある通り、**Dockerのsecret volume機能を使う**ことです。

- PATを適当なローカルのファイルに保存する (下記のMYPERSONALSECURITYTOKENを、実際のPAT文字列に置き換えてください)。
```sh
cat MYPERSONALSECURITYTOKEN > ~/.nuget/tokens/mytoken
```
- 上記の`Dockerfile`の最終行を以下のように変更する
```Dockerfile
RUN --mount=type=secret,id=mytoken VSS_NUGET_EXTERNAL_FEED_ENDPOINTS='{"endpointCredentials":[{"endpoint":"'${FEED}'","username":"name","password":"'$(cat /run/secrets/mytoken)'"}]}' dotnet restore -s ${FEED} "MyProject/MyProject.csproj"
```

`RUN`コマンドに`--mount=type=secret`を指定するのがポイントです (イコールが2個使われてて変な感じがしますが)。PATを使ってNuGetフィードから読む場合は`username`は空文字列以外なら何でもいいので、そのまま`name`としてあります。

- `docker build`を`--secret`オプションをつけて実行する
```sh
DOCKER_BUILDKIT=1 docker build --secret id=mytoken,src=$HOME/.nuget/tokens/mytoken -t myproj:1.0.0 .
```
これで`/run/secrets/mytoken`がローカル側の`~/.nuget/tokens/mytoken`に置き換えられます。**`docker build`の実行履歴にもクレデンシャルが表示されないこともポイントです**。`src=`には最初`~/.nuget/tokens/mytoken`と指定したのですが`~`をホームディレクトリに置き換えてくれずエラーになったので`$HOME`としてます。うーむ。
`DOCKER_BUILDKIT=1`は`--secret`を使うために必要な環境変数です。あらかじめ`export DOCKER_BUILDKIT=1`しておけば不要です。まあお好みで。

# 最終形

というわけで私の`Dockerfile`はこんな感じになりました (フィードのURL等は隠してます)。
```Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
ENV ASPNETCORE_URLS="https://+;http://+"

FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
# Auth with private feed
WORKDIR /tool
RUN wget https://raw.githubusercontent.com/Microsoft/artifacts-credprovider/master/helpers/installcredprovider.sh \
   && chmod +x installcredprovider.sh \
   && ./installcredprovider.sh

WORKDIR /src
COPY ["./MyProject.csproj", "MyProject/"]

ARG FEED=https://pkgs.dev.azure.com/...../nuget/v3/index.json

RUN --mount=type=secret,id=mytoken VSS_NUGET_EXTERNAL_FEED_ENDPOINTS='{"endpointCredentials":[{"endpoint":"'${FEED}'","username":"name","password":"'$(cat /run/secrets/mytoken)'"}]}' dotnet restore -s ${FEED} "MyProject/MyProject.csproj"

COPY . MyProject
WORKDIR "/src/MyProject"
RUN dotnet build "MyProject.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "MyProject.csproj" -c Release -o /app/publish

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "MyProject.dll"]
```

ビルドコマンド
```sh
DOCKER_BUILDKIT=1 docker build --secret id=mytoken,src=$HOME/.nuget/tokens/mytoken -t myproj:1.0.0 .
```

# まとめ
基本的には参考にしたドキュメント群にすべて書かれていたのですが、「NuGetのプライベートfeedをDockerfileでセキュアに読む」というニッチな問題そのものズバリのドキュメントがなかったので自分用のメモとして書きました。他の方の参考にもなれば幸いです。

# 参考文献
1. https://docs.microsoft.com/en-us/azure/devops/artifacts/get-started-nuget?view=azure-devops&tabs=windows
1. https://stackoverflow.com/questions/57362453/artifacts-credprovider-and-vss-nuget-external-feed-endpoints-with-docker
1. https://medium.com/marionete/pass-secure-information-for-building-docker-images-8adeafe08355
1. https://www.dudley.codes/posts/2020.12.28-dockerfile-build-args-exposed/