首頁

目前文章總數:190 篇

  

最後更新:2025年 07月 26日

0096. ASP.NET Core + Docker 環境變數金鑰管理:用 Jenkins 實現權限分離與 Secret Key 注入

日期:2025年 07月 05日

標籤: Docker Container Ubuntu Linux SHA256 HMAC Jenkins Continuous Integration(CI) Continuous Deployment(CD) YAML Asp.NET Core Web MVC

摘要:C# 學習筆記


應用所需:1. Linux Ubuntu (本篇 22.04)
     2. 已安裝 Docker
     3. 已安裝 Jenkins
解決問題:1. 說明實際生產 IT 人員如何保管金鑰,與開發人員如何使用此金鑰(不知道真實金鑰的情況)Hash 加密結果
     2. 如何從 Jenkins 部署 Container 並且注入金鑰參數,讓 ASP.NET C# 代碼可以讀取,並且展示
範例專案:範例代碼
基本介紹:本篇分為 5 部分。
第一部分:金鑰管理架構 & 解決問題
第二部分:專案架構
第三部分:代碼說明
第四部分:Jenkins & Docker 的部署流程 & DEMO 成果
第五部分:Jenkins 上密文保管 Secret Key & DEMO 成果






第一部分:金鑰管理架構 & 解決問題

Step 1:金鑰管理架構

1. 開發簽入代碼 開發人員將代碼準備完成,依照 2. IT 人員提供的金鑰參數
2. 開發 & IT 協調 依照兩邊協調的結果,開發人員持續用相同的參數取得金鑰 (※本篇環境參數用 security_key )
3. IT 人員進行部署準備 依照協調的結果,IT 執行部署準備 (金鑰保管等)
4. IT 使用金鑰部署執行 正式執行部署,IT 人員會知道金鑰
5. 回傳金鑰結果 部署完成後,開發人員的代碼依照與 IT 人員協定的方式,獲取生產金鑰




Step 2:解決問題

主要是解決不讓開發人員 -> 直接取得金鑰 ,而是透過 IT 維運人員進行保管
※這邊身分可以依照實務狀況替換


第二部分:專案架構

Step 1:範例專案架構

打開範例代碼後,架構基本分成以下:

1. Web 控制器 檢視控制器,用來驗證說明,並用 SHA 256 結合字串加密
2. Model 模型 呼叫 C# 的內建靜態方法,取得環境變數上的參數
3. launchSettings 本機 Debug 模式時,預設內建的環境參數
4. DockerFile 部署時預設使用的 DockerFile
5. JenkinsPipeline Jenkins 執行部署的腳本,Build Image 然後運行 Container




第三部分:代碼說明

Step 1:Web 控制器

檢視 Index 首頁,主要呼叫 ViewModel
並且將取得的 sercurity_key 做 SHA256 加密,我們假定字串都是 Account:Louis
※實務上這個字串就是會員資料庫的帳號,要結合金鑰做加密結果

public IActionResult Index()
{
    var getEnviromentInfo = new ContainerEnvironmentModel();
    getEnviromentInfo.SecurityKeyHashMAC = ComputeHMACSHA256("Account:Louis", getEnviromentInfo.SecurityKey);
    return View(getEnviromentInfo);
}

/// <summary>
/// HMAC SHA 256 加密
/// </summary>
/// <param name="message">原始字串</param>
/// <param name="key">金鑰</param>
/// <returns></returns>
static string ComputeHMACSHA256(string message, string key)
{
    byte[] keyBytes = Encoding.UTF8.GetBytes(key);
    byte[] messageBytes = Encoding.UTF8.GetBytes(message);

    using (HMACSHA256 hmac = new HMACSHA256(keyBytes))
    {
        byte[] hashBytes = hmac.ComputeHash(messageBytes);

        StringBuilder builder = new StringBuilder();
        foreach (var b in hashBytes)
        {
            builder.Append(b.ToString("x2"));
        }

        return builder.ToString();
    }
}



Step 2:Model 模型

使用 C# 內建的靜態方法,呼叫環境變數上的參數 ASPNETCORE_ENVIRONMENT , DOTNET_ENVIRONMENT , security_key
其中 security_key 是 IT 人員會提供的一個接口,負責的開發工程師要取得此參數

namespace GetDockerContainerEnvironmentParameterExample.Models
{
    public class ContainerEnvironmentModel
    {
        public string AspNetCoreEnvironment =>
                    Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? string.Empty;

        public string DotNetEnvironment =>
            Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? string.Empty;

        public string SecurityKey =>
            Environment.GetEnvironmentVariable("security_key") ?? string.Empty;

			
        public string SecurityKeyHashMAC { get; set; }
    }
}



Step 3:launchSettings

為了便於本機驗證,可以在 launchSettings.json 中配置對應的 Environment 參數,可以確保有抓到正確的資料
如下,為 httphttpsIIS Express 執行 Debug 偵錯時,增加攜帶參數

{
  "$schema": "http://json.schemastore.org/launchsettings.json",
  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "http://localhost:56870",
      "sslPort": 44326
    }
  },
  "profiles": {
    "http": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "applicationUrl": "http://localhost:5246",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development",
        "DOTNET_ENVIRONMENT": "Development",
        "security_key": "MyLocalKey"
      }
    },
    "https": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "applicationUrl": "https://localhost:7186;http://localhost:5246",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development",
        "DOTNET_ENVIRONMENT": "Development",
        "security_key": "MyLocalKey"
      }
    },
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development",
        "DOTNET_ENVIRONMENT": "Development",
        "security_key": "MyLocalKey"
      }
    }
  }
}



Step 4:DockerFile

運行容器都需要 DockerFile 或 Docker-compose.yml ,提供一個內建的檔案
對於 IT 人員的生產部署,是可以用來參考,但 IT 人員可以自行決定定義參數,並與開發人員溝通可使用參數

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base

ENV ASPNETCORE_ENVIRONMENT=DEV
ENV DOTNET_ENVIRONMENT=DEV
ENV security_key=DEVTestKey

WORKDIR /app
EXPOSE 8080
EXPOSE 8081

COPY ./publish .

RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
RUN echo 'Asia/Shanghai' > /etc/timezone

ENTRYPOINT ["dotnet", "GetDockerContainerEnvironmentParameterExample.dll"]



Step 5:JenkinsPipeline

Jenkins Pipeline 腳本定義了環境配置加上 7 個 Stage 步驟
※此腳本符合小型專案架構,若中、大型、請採用 K8S 並且有統一的 Hub 處理管理Image(DockerHub / Harbor)

1. 基本配置 部署的專案名稱、容器名稱、 Git 檔案來源、部署位置
2. Stage 1 - Checkout 從版控上取得代碼,這裡是從 Github 上抓取範例代碼
3. Stage 2 - Building 建置該代碼,使用 dotnet build ,確保專案是可編譯
4. Stage 3 - Publish Main Host 將編譯後的檔案部署到遠端機器
5. Stage 4 - Publish DockerFile 將 DockerFile 放在正確的位置,可以依照實務狀況,決定是否此步驟需要
6. Stage 5 - Build Image Remotely 建立 Docker Image
7. Stage 6 - ReConstruct Container 重建容器,即使有相同的容器運行,也會安全的重建 (非零停機部署的實踐)
8. Stage 7 - Image Purne 清空不需要的 Image ,節省空間
pipeline {
  agent any
  
  // 環境變數 【實務上依照自己的機器配置替換】
  environment {       		
        PROJECT_NAME = "GetDockerContainerEnvironmentParameterExample"// 專案名稱
		PROJECT_NAME_FOR_DOCKER = "getdockercontainerenvironmentparameterexample"// DockerName 強制小寫
		GIT_SOURCE_REPOSITORY = "https://github.com/gotoa1234/MyBlogExample.git"// 專案來源
		TARGET_MACHINE_IP = "192.168.51.93"// 對應的部署機器IP
		TARGET_MACHINE_CREDENTIAL = "DeployMechineUbuntu"// 對應部署機器的SSH Server Name
  }  
  
  // 定義單一建置時可異動參數 【實務上依照自己的機器配置替換參數】
  parameters {
        string(name: 'GIT_HASH_TAG', defaultValue: '', description: '指定發布的GIT Hash 標籤(雜湊版號),預設 head 表示更新最新代碼')
        string(name: 'ASPNETCORE_ENVIRONMENT', defaultValue: 'ProductionASPCORE', description: 'ASP NETCORE 環境變數')
        string(name: 'DOTNET_ENVIRONMENT', defaultValue: 'ProductionDotnet', description: 'DOTNET NETCORE 環境變數')
        string(name: 'security_key', defaultValue: 'ProductionKey', description: 'IT 管理金鑰')
  }
  
  stages {
      
    // step 1. start
    stage('Checkout') {
       steps {
            checkout([$class: 'GitSCM', 
                branches: [[name: "remotes/origin/main"]],
                userRemoteConfigs: [[url: "${env.GIT_SOURCE_REPOSITORY}"]]
            ])

            sh """
                git pull origin main
            """

            sh """
                git checkout ${params.GIT_HASH_TAG}
            """
        }
     }
    // step 1. end

    // step 2. start
    stage('Building') {
      steps {
        script {
                    sh """
                      dotnet publish ${PROJECT_NAME}/${PROJECT_NAME}.csproj -c Release -o publish/${PROJECT_NAME} --disable-build-servers
                    """
                }
      }
    }
    // step 2. end
    
    // step 3. start
    stage('Publish Main Host') {
	  steps {
	      sshPublisher(publishers: 
	             [sshPublisherDesc(configName: "${env.TARGET_MACHINE_CREDENTIAL}", 
	                                transfers: [
	                                    sshTransfer(cleanRemote: true, 
	                                                   excludes: '', 
	                                                execCommand: '', 
	                                                execTimeout: 120000, 
	                                                    flatten: false, 
	                                              makeEmptyDirs: false, 
	                                          noDefaultExcludes: false, 
	                                           patternSeparator: '[, ]+', 
	                                            remoteDirectory: "var\\dockerbuildimage\\${PROJECT_NAME}\\publish", 
	                                         remoteDirectorySDF: false, 
	                                               removePrefix: "publish\\${PROJECT_NAME}", 
	                                                sourceFiles: "publish\\${PROJECT_NAME}\\**")],
	                     usePromotionTimestamp: false, 
	                   useWorkspaceInPromotion: false, 
	                                   verbose: false)
	             ])
	  }
    }
    // step 3. end
    
	// step 4. start
    stage('Publish DockerFile') {
	  steps {
	      sshPublisher(publishers: 
		         [sshPublisherDesc(configName: "${env.TARGET_MACHINE_CREDENTIAL}", 
				                    transfers: [
									    sshTransfer(cleanRemote: false, 
										               excludes: '', 
													execCommand: '', 
													execTimeout: 120000, 
													    flatten: false, 
												  makeEmptyDirs: false, 
											  noDefaultExcludes: false, 
											   patternSeparator: '[, ]+', 
											    remoteDirectory: "var\\dockerbuildimage\\${PROJECT_NAME}", 
											 remoteDirectorySDF: false, 
											       removePrefix: "${PROJECT_NAME}", 
												    sourceFiles: "${PROJECT_NAME}\\Dockerfile")], 
					   usePromotionTimestamp: false, 
					 useWorkspaceInPromotion: false, 
					                 verbose: false)
				])
	  }
    }
    // step 4. end
	
	// step 5. start
    stage('Build Image Remotely') {
      steps {
	    sh """
		   echo cd /var/dockerbuildimage/${env.PROJECT_NAME} 
		   echo docker build --no-cache -t ${env.PROJECT_NAME_FOR_DOCKER} .
		   echo docker tag ${env.PROJECT_NAME_FOR_DOCKER}:latest ${env.PROJECT_NAME_FOR_DOCKER}:hash_${params.GIT_HASH_TAG}
		   """
	  
        sshPublisher(
            failOnError: true,
            publishers: [sshPublisherDesc(
            configName: "${env.TARGET_MACHINE_CREDENTIAL}", 
            transfers: [sshTransfer(
                excludes: '', 
                execCommand: "cd /var/dockerbuildimage/${env.PROJECT_NAME} && \
                              docker build --no-cache -t ${env.PROJECT_NAME_FOR_DOCKER} . && \
                              docker tag ${env.PROJECT_NAME_FOR_DOCKER}:latest ${env.PROJECT_NAME_FOR_DOCKER}:hash_${params.GIT_HASH_TAG}", 
                execTimeout: 120000, 
                patternSeparator: '[, ]+')], 
            verbose: false)])
      }
    }
	// step 5. end
	
	// step 6. start
    stage('ReConstruct Container') {
      steps {
        sshPublisher(
            failOnError: true,
            publishers: [sshPublisherDesc(
            configName: "${env.TARGET_MACHINE_CREDENTIAL}", 
            transfers: [sshTransfer(
                excludes: '', 
                execCommand: "sudo docker stop ${env.PROJECT_NAME_FOR_DOCKER} && \
                                  docker rm ${env.PROJECT_NAME_FOR_DOCKER} || true && \
                                  docker run -e ASPNETCORE_ENVIRONMENT=${params.ASPNETCORE_ENVIRONMENT} -e DOTNET_ENVIRONMENT=${params.DOTNET_ENVIRONMENT} -e security_key=${params.security_key} --name ${env.PROJECT_NAME_FOR_DOCKER}_A -d -p 8090:8080 -p 8190:8081 \
                                  --mount type=bind,source=/var/dockervolumes/${env.PROJECT_NAME}/appsettings.json,target=/app/appsettings.json \
                                  --mount type=bind,source=/var/dockervolumes/${env.PROJECT_NAME}/appsettings.Development.json,target=/app/appsettings.Development.json \
                                  ${env.PROJECT_NAME_FOR_DOCKER}:latest", 
                execTimeout: 120000, 
                patternSeparator: '[, ]+')], 
            verbose: false)])
      }
    }   
	// step 6. end
	
	// step 7. start
    stage('Image Purne') {
      steps {
         sshPublisher(
             failOnError: true,
             publishers: [sshPublisherDesc(
             configName: "${env.TARGET_MACHINE_CREDENTIAL}", 
             transfers: [sshTransfer(
                 excludes: '', 
                 execCommand: "docker image prune -f", 
                 execTimeout: 120000, 
                 patternSeparator: '[, ]+')], 
             verbose: false)])
      }
    }
	// step 7. end
  }
}



第四部分:Jenkins & Docker 的部署流程 & DEMO 成果

Step 1:Jenkins - 建立 Job 模擬生產部署

於 Jenkins 上建立 1 個 Job ,模擬 IT 人員會進行的部署


Step 2:Jenkins - 建立 Job 模擬生產部署

對應範例代碼的 JenkinsPipeline 腳本,會出現以下環境參數, IT 人員建置時可決定參數添加何者
※要隱藏參數可參考第五部分


Step 3:Jenkins - 生產部署完成

預期會順利部署


Step 4:確認 Container 狀態

部署完成後,容器若順利執行,可以執行 Inspect 或 Portainer 檢視
部署的參數都在容器上


Step 5:DEMO 結果

對於開發人員來說,代碼端只要與 IT 人員確認好參數,都可以正確取得到 環境參數
通常會將 security_key 進行 HMAC 加密使用
※這邊是示範可以直接顯示,實務上不可能讓 security_key 明文 顯示


Step 6:遺留問題 : IT 端為明文的風險

IT 是一個 Team 通常並不會讓所有人都看到 security_key 的明文,因此保管上必須做處理
只讓最初的生產金鑰的 IT 人員知道真正的明文


第五部分:Jenkins 上密文保管 Secret Key & DEMO 成果

Step 1:解決 IT 明文顯示 security_key 問題

為了解決明文的高風險問題,可先進入 Jenkins 管理中


Step 2:解決明文問題 - 進入 Credentials

選擇 Credentials


Step 3:解決明文問題 - Global

進入 Global


Step 4:解決明文問題 - 增加 Credentials 項目

選擇 Add Credentials


Step 5:解決明文問題 - 產生 sercret_key

產生 security_key 專屬的密文項目


Step 6:解決明文問題 - 複製ID

將產生的 GUID 複製 (也可以自定義)


Step 7:解決明文問題 - 調整 Pipeline 配置

將 security_key 從 parameters 改為放在 environment 中
並且替換成以下

security_key = credentials("您的 security_key ID") 




Step 8:解決明文問題 - 調整 Pipeline 建構容器

因為從 parmeter 改為 environment ,因此建構容器的地方也需同步調整


Step 9:解決明文問題 - Jenkins 部署

使用新腳本再次進行部署


Step 10:解決明文問題 - Jenkins 參數化建置

這次建置不在出現 security_key 的選項,並且在 Pipeline 腳本中,也以 credential 的方式隱藏


Step 11:解決明文問題 - Demo 成果

順利部署後,除了建立金鑰的 IT 人員之外,很大程度減少明文被檢視
※代碼仍需走查,因為還是有機會從代碼端讀出此參數 / 也有可能從生產部署機器檢視此金鑰