﻿# 如何在 Windows Containers 快速建立 IIS 站台與應用程式集區設定

IIS 是一個相對穩定的產品，從 Windows Server 2016 推出 IIS 10.0 之後，版號就不再更動，而且每次作業系統釋出新版本也只會微幅新增功能，因此整體架構並沒有任何變化。本篇文章將分享我在管理 IIS 容器的一些心得與技巧。

### 建立容器

官方的 `mcr.microsoft.com/dotnet/framework/aspnet:4.8` 容器映象，已經預先安裝好 `Web Server (IIS)` 與 `ASP.NET 4.8` 角色服務，我將會使用這個 image 為主要的執行環境。

```powershell
docker run --name=mysite -d --isolation=process -p 80:80 -p 443:443 -v C:\Projects\WebApplication1:C:\Inetpub\wwwroot mcr.microsoft.com/dotnet/framework/aspnet:4.8
```

以下對上述參數進行說明：

-   `--name=mysite` 設定容器好記名稱為 `mysite`，方便後續命令說明。
-   `-d` 代表我們要將容器執行在 detach 模式（背景執行）。
-   `--isolation=process` 由於我們要複製現有的 PFX 憑證進容器，在 Windows 10 的 Windows Containers 必須要使用 `proccess` 隔離模式執行，才能執行 `docker cp` 命令複製檔案。
-   `-p 80:80 -p 443:443` 由於 Windows Containers 執行容器時，預設採用 `nat` 模式，所以需要將容器的 `IP:Ports` 對應到本機 `Ports` 比較方便測試。
-   `-v C:\Projects\WebApplication1:C:\Inetpub\wwwroot` 將一個現有的 ASP.NET MVC 5 專案對應到容器的 `C:\Inetpub\wwwroot` 路徑。
-   `--entrypoint powershell` 因為微軟官方的 `aspnet` 容器映象預設只能跑在 `detach` (`-d`) 模式，為了方便我們測試，我想改用 `powershell` 來當成預設進入程式。
-   `mcr.microsoft.com/dotnet/framework/aspnet:4.8` 已經預載 `Web Server (IIS)` 與 `ASP.NET 4.8` 角色服務。這是微軟官方的「多架構」容器映象，詳見 [Windows Container 版本相容性與多重架構容器映像介紹](/post/2019/06/24/Windows-Containers-version-compatibility-and-multi-arch-images) 文章。

接著透過 `docker exec` 進入容器中操作：

```powershell
docker exec -it -w c:\inetpub\wwwroot mysite powershell
```

我個人會特別將 PowerShell 加入一些快速鍵設定：

```powershell
mkdir C:\Users\ContainerAdministrator\Documents\WindowsPowerShell

'# 設定按下 Ctrl+d 可以退出 PowerShell 執行環境
Set-PSReadlineKeyHandler -Chord ctrl+d -Function ViExit
# 設定按下 Ctrl+w 可以刪除一個單字
Set-PSReadlineKeyHandler -Chord ctrl+w -Function BackwardDeleteWord
# 設定按下 Ctrl+e 可以移動游標到最後面(End)
Set-PSReadlineKeyHandler -Chord ctrl+e -Function EndOfLine
# 設定按下 Ctrl+a 可以移動游標到最前面(Begin)
Set-PSReadlineKeyHandler -Chord ctrl+a -Function BeginningOfLine' | Out-File $PROFILE

. $PROFILE
```

### 複製現有 IIS 站台設定

我所認識的客戶與朋友中，大部分的人對 IIS 都是採用「設定飄移」(Configuration Drift)方法進行設定，也就是用「完全人工」的方式調整 IIS 中的任何設定。這種習慣會造成幾個問題：

1.  隨心所欲的設定，其結果就是「忘記到底設定過些什麼」，然後在重新安裝時，無法「調」出跟原本站台完全相同的設定。
2.  當你想建立容器時，由於容器中並沒有 UI 介面可以設定，需要大量仰賴 Windows PowerShell 來進行設定，對許多人來說是個門檻。

要將現有 IIS 站台的設定「複製」到另外一台，其實並不複雜，只要你能懂得一些基礎原理即可。

IIS 中所有的設定，其實是一種「階層結構」，整台主機有一個「根」設定，位於以下路徑：

```
C:\Windows\System32\inetsrv\config\applicationHost.config
```

然後站台實體目錄的根目錄下還有一個「本地」設定，如果實體目錄位於 `c:\inetpub\wwwroot` 的話，就是這個檔案：

```
c:\inetpub\wwwroot\web.config
```

但是你在 IIS 管理員中，透過 UI 所做的那些設定，有些會寫入「根設定」，有些則會寫入「本地設定」，你從 IIS 管理員那邊是完全看不出來的，必須開啟這兩個檔案，比對後才能知道到底改在哪裡。除非你很有經驗，否則肉眼無法判斷。

如果我們想把目前電腦上的 IIS 把所有站台移到另外一台主機，最簡單的方式是直接把「根設定」複製到新電腦的同名路徑下覆蓋即可！

不過，直接設定會有一些風險：

1.  如果新電腦缺少安裝特定 IIS 模組，複製進去後 IIS 就無法啟動。
2.  如果新電腦沒有預先安裝好 SSL 憑證，複製進去後，站台也無法正常瀏覽 HTTPS 安全網頁。

簡單歸簡單，上述方法應該可以說是最沒有門檻的網站搬移法。但是，請記得搬移完之後，要執行 `iisreset /restart` 重新啟動 IIS 服務！

### 將根設定複製進 IIS 容器的注意事項

將 `C:\Windows\System32\inetsrv\config\applicationHost.config` 設定檔複製進容器後，有很大的機率會讓你的 IIS 無法讀取的正確的設定，而且之後再透過 PowerShell 調整的 IIS 設定時，就算當下設定可以生效，但最終還是無法寫入到設定檔之中，導致容器重啟後設定依然沒有套用的情況。

此時在容器中執行 `iisreset /restart` 重新啟動 IIS 服務後，該容器就會自動停止！因為 IIS 就是我們當前容器的「主程序」，而容器的運作基本原理，就是當「主程序」停止時，容器就會自動停止！所以你要在容器停止後，重新啟動 IIS 容器即可：

```powershell
docker start mysite
```

我一般來說，都會先將 `applicationHost.config` 設定檔複製出來，並且跟我目前 IIS 站台的 `applicationHost.config` 設定檔比對，然後將想加入的設定搬到容器的 `applicationHost.config` 裡面。以下是常用的 `docker cp` 命令：

-   從容器複製到本機
    
    ```powershell
    docker cp mysite:C:\Windows\System32\inetsrv\config\applicationHost.config g:\applicationHost.config
    ```
    
-   從本機複製到容器
    
    ```powershell
    docker cp g:\applicationHost.config mysite:C:\Windows\System32\inetsrv\config\applicationHost.config
    ```
    

### 直接複製 `applicationHost.config` 會引發的問題

這個問題是可以輕易重現的，假設我們的容器中站台設定如下：

```
PS C:\> Get-IISSite

Name             ID   State      Physical Path                  Bindings
----             --   -----      -------------                  --------
Default Web Site 1    Started    %SystemDrive%\inetpub\wwwroot  http *:80:
```

然而我本機的 IIS 設定如下：(多了一個 `http *:81:` 繫結)

```
PS C:\> Get-IISSite

Name             ID   State      Physical Path                  Bindings
----             --   -----      -------------                  --------
Default Web Site 1    Started    %SystemDrive%\inetpub\wwwroot  http *:80:
                                                                http *:81:
```

你只要將本機修改過的 `applicationHost.config` 複製到容器中，然後你就會發現，站台的新設定並沒有生效：

```
PS C:\> Get-IISSite

Name             ID   State      Physical Path                  Bindings
----             --   -----      -------------                  --------
Default Web Site 1    Started    %SystemDrive%\inetpub\wwwroot  http *:80:
```

但是用 `Get-Website` 又會看到新的設定：

```
PS C:\> Get-Website

Name             ID   State      Physical Path                  Bindings
----             --   -----      -------------                  --------
Default Web Site 1    Started    %SystemDrive%\inetpub\wwwroot  http *:80:
                                                                http *:81:
```

> 當你遇到這種鬼打牆的情況，就真的很難理解其中的道理！到底哪個才是對的？
> 
> 由於 [New-IISSite](https://learn.microsoft.com/en-us/powershell/module/iisadministration/new-iissite) 來自於 [IISAdministration](https://learn.microsoft.com/en-us/powershell/module/iisadministration/) 模組，而 [New-Website](https://learn.microsoft.com/en-us/powershell/module/webadministration/new-website) 來自於 [WebAdministration](https://learn.microsoft.com/en-us/powershell/module/webadministration/) 模組。這兩個模組是這樣的：
> 
> -   [WebAdministration](https://learn.microsoft.com/en-us/powershell/module/webadministration/)：在 Windows Server 2012 **以前**都用這個。
> -   [IISAdministration](https://learn.microsoft.com/en-us/powershell/module/iisadministration/)：在 Windows Server 2016 **以後**都用這個。
> 
> 簡單來說，你要在 Windows Container 中使用 PowerShell 管理 IIS 的話，使用 [IISAdministration](https://learn.microsoft.com/en-us/powershell/module/iisadministration/) 就對了！

接著你直接執行以下命令建立站台：

```powershell
New-IISSite -Name Test -PhysicalPath "C:\inetpub\wwwroot" -BindingInformation "*:80:localhost"
```

你會看到以下錯誤：

```
New-IISSite : Filename: \\?\C:\Windows\system32\inetsrv\config\applicationHost.config
Error: Cannot commit configuration changes because the file has changed on disk
At line:1 char:1
+ New-IISSite -BindingInformation ":80:localhost" -PhysicalPath "C:\ine ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [New-IISSite], FileLoadException
    + FullyQualifiedErrorId : System.IO.FileLoadException,Microsoft.IIS.Powershell.Commands.NewIISSiteCommand
```

![Error: Cannot commit configuration changes because the file has changed on disk](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/0188111c2a9b49619199-88352423-454cd480-cd8c-11ea-9bd0-2dc366bc8dd4.png)

重點在這裡：

```
Error: Cannot commit configuration changes because the file has changed on disk
```

然後你就會發現，剛剛的命令「感覺上」有設定成功，也就是新的站台設定「有生效」：

```
PS C:\> Get-IISSite

Name             ID   State      Physical Path                  Bindings
----             --   -----      -------------                  --------
Default Web Site 1    Started    %SystemDrive%\inetpub\wwwroot  http *:80:
Test             8712            C:\inetpub\wwwroot             http *:80:localhost
```

如果你再執行一次，會遇到另一個錯誤：

```
New-IISSite : Web site 'Test' already exists.
At line:1 char:1
+ New-IISSite -Name Test -PhysicalPath "C:\inetpub\wwwroot" -BindingInf ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : NotSpecified: (:) [New-IISSite], ArgumentException
    + FullyQualifiedErrorId : System.ArgumentException,Microsoft.IIS.Powershell.Commands.NewIISSiteCommand
```

![New-IISSite : Web site 'Test' already exists.](https://stwillblogassets.blob.core.windows.net/files/images/external/user-images.githubusercontent.com/2eff9bfe1ae54a616286-88352456-61e90c80-cd8c-11ea-96df-f6f994d9a3ee.png)

這時你去執行 `iisreset /restart` 並重新啟動容器 `docker start mysite`，再進入容器用 `Get-IISSite` 檢查，就會發現站台並沒有被新增進去：

```
PS C:\> Get-IISSite

Name             ID   State      Physical Path                  Bindings
----             --   -----      -------------                  --------
Default Web Site 1    Started    %SystemDrive%\inetpub\wwwroot  http *:80:
                                                                http *:81:
```

所以，請記得在複製 `applicationHost.config` 進容器後，立刻執行 `iisreset /restart` 並重新啟動容器！

### 相關連結

-   [PowerShell and IIS: 20 practical examples - Octopus Deploy](https://octopus.com/blog/iis-powershell)
-   [如何在 Windows Containers 建立內含正式 SSL/TLS 憑證的 IIS 網站](/post/2020/07/12/Windows-Container-IIS-binding-https-import-pfx)
-   [如何在 Windows Containers 的 aspnet 容器新增額外的 IIS 角色服務](/post/2020/07/12/Windows-Containers-IIS-install-additional-role-service)
-   [Introducing IISAdministration in the PowerShell Gallery](https://blogs.iis.net/iisteam/introducing-iisadministration-in-the-powershell-gallery)
-   [What is Configuration Drift?](https://www.tripwire.com/state-of-security/security-data-protection/what-is-configuration-drift/)
-   [Internet Information Services - Wikipedia](https://en.wikipedia.org/wiki/Internet_Information_Services)
