

<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Azure 筆記 &#8211; Max的程式語言筆記</title>
	<atom:link href="https://stackoverflow.max-everyday.com/category/azure/feed/" rel="self" type="application/rss+xml" />
	<link>https://stackoverflow.max-everyday.com</link>
	<description>我要當一個豬頭，快樂過每一天</description>
	<lastBuildDate>Wed, 03 Jun 2026 13:48:52 +0000</lastBuildDate>
	<language>zh-TW</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0</generator>

<image>
	<url>https://stackoverflow.max-everyday.com/wp-content/uploads/2017/02/max-stackoverflow-256.png</url>
	<title>Azure 筆記 &#8211; Max的程式語言筆記</title>
	<link>https://stackoverflow.max-everyday.com</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>A minimum subnet size of /24 is recommended for Application Gateway v2 subnets</title>
		<link>https://stackoverflow.max-everyday.com/2026/06/a-minimum-subnet-size-of-24-is-recommended-for-application-gateway-v2-subnets/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/06/a-minimum-subnet-size-of-24-is-recommended-for-application-gateway-v2-subnets/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Wed, 03 Jun 2026 13:48:51 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[azure]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8519</guid>

					<description><![CDATA[Via Azure CLI # Add new ...]]></description>
										<content:encoded><![CDATA[
<figure class="wp-block-image size-large"><img fetchpriority="high" decoding="async" width="1024" height="453" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/06/2026-06-03_21-47_o4-1024x453.jpg?v=1780494450" alt="" class="wp-image-8520" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/06/2026-06-03_21-47_o4-1024x453.jpg?v=1780494450 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/06/2026-06-03_21-47_o4-600x265.jpg?v=1780494450 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/06/2026-06-03_21-47_o4-768x339.jpg?v=1780494450 768w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/06/2026-06-03_21-47_o4.jpg?v=1780494450 1224w" sizes="(max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">Via Azure CLI</p>



<p class="wp-block-paragraph"># Add new /24 subnet<br>az network vnet subnet create \<br>&#8211;resource-group rg-spoke-paas \<br>&#8211;vnet-name \<br>&#8211;name snet-appgw-new \<br>&#8211;address-prefix 10.x.x.0/24</p>



<p class="wp-block-paragraph"># Then update AGW to use new subnet<br>az network application-gateway update \<br>&#8211;name agw-stg-jpe-001 \<br>&#8211;resource-group rg-spoke-paas \<br>&#8211;set gatewayIPConfigurations[0].subnet.id=</p>



<p class="wp-block-paragraph">Note: AGW subnet IP update via CLI may require a stop/start cycle. Plan for downtime or use blue/green deployment.</p>



<p class="wp-block-paragraph">Would you like help calculating an available /24 block in your VNet, or generating the full ARM/Bicep template?</p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/06/a-minimum-subnet-size-of-24-is-recommended-for-application-gateway-v2-subnets/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Azure SQL Database（以及 Azure SQL Managed Instance）完全支援「透明資料加密」</title>
		<link>https://stackoverflow.max-everyday.com/2026/06/azure-sql-database-tde/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/06/azure-sql-database-tde/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Wed, 03 Jun 2026 07:00:51 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[azure]]></category>
		<category><![CDATA[mssql]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8510</guid>

					<description><![CDATA[公司的政策, 資料庫要透明加密, 並給我 sql...]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">公司的政策, 資料庫要透明加密, 並給我 sql server command:</p>



<pre class="wp-block-code"><code>-- 查詢憑證是否存在
USE master;
SELECT * FROM sys.certificates WHERE name = 'TdeServerCert';

-- (補充) 記錄備份
BACKUP LOG 資料庫名稱 
TO DISK = 'D:\DB_backup\path_to_backup_file.trn';

-- 建議創建主要金鑰與憑證，主要金鑰的密碼很重要，請記得！！！
USE master;
GO
--創建主要金鑰(密碼請更換，勿用範例的密碼)
CREATE MASTER KEY ENCRYPTION BY PASSWORD = 'TDE@9876543210';
GO
--創建加密憑證
CREATE CERTIFICATE TdeServerCert 
WITH SUBJECT = N'DB_TDE_Certificat',
EXPIRY_DATE = '2099-12-31'; --憑證到期的日期
GO</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">在 azure 上執行 USE master; 就顯示錯誤訊息了.</p>



<pre class="wp-block-preformatted">USE statement is not supported to switch between databases. Use a new connection to connect to a different database.<br>Failed to execute the query</pre>



<p class="wp-block-paragraph"><strong>可以，Azure SQL Database（以及 Azure SQL Managed Instance）完全支援「透明資料加密」（Transparent Data Encryption，簡稱 TDE）。</strong> 事實上，在 Azure 上，所有新建立的資料庫<strong>預設都是直接開啟 TDE 的</strong>，你不需要動手去啟動它。</p>



<p class="wp-block-paragraph">至於你遇到的錯誤訊息，是因為 <strong>Azure SQL Database 的架構不支援 <code>USE [資料庫名稱]</code> 語法</strong>來切換資料庫。在 Azure SQL 中，如果你想查詢不同的資料庫（例如 <code>master</code>），你必須在連線工具（如 SSMS 或 Azure Data Studio）中，<strong>直接修改連線字串或連線屬性中的資料庫名稱</strong>，重新連線進去。</p>



<p class="wp-block-paragraph">以下為你整理 Azure SQL TDE 的運作方式，以及如何正確查詢它的狀態：</p>



<h3 class="wp-block-heading">1. 為什麼不需要在 Azure 建立 TDE 憑證？</h3>



<p class="wp-block-paragraph">在傳統的 On-Premises（地端）SQL Server 中，你要做 TDE 必須自己手動建立 Master Key、憑證（Certificate），然後再針對資料庫做加密（這也就是你預期去 <code>sys.certificates</code> 找 <code>TdeServerCert</code> 的原因）。</p>



<p class="wp-block-paragraph">但在 <strong>Azure SQL Database</strong> 中：</p>



<ul class="wp-block-list">
<li><strong>服務管理金鑰 (Service-Managed Key)</strong>：Azure 預設會自動幫你管理 TDE 憑證與金鑰，並定期自動輪換（Rotate）。你不需要、也沒辦法在 <code>master</code> 資料庫裡看到或管理這個由 Azure 託管的憑證。</li>



<li><strong>客戶管理金鑰 (Customer-Managed Key, BYOK)</strong>：如果你有合規性需求，想要自己掌控金鑰，你可以結合 <strong>Azure Key Vault</strong> 來實作 BYOK（Bring Your Own Key）。</li>
</ul>



<h3 class="wp-block-heading">2. 如何正確檢查 Azure SQL 的 TDE 狀態？</h3>



<p class="wp-block-paragraph">你不需要切換到 <code>master</code> 去找憑證，直接在<strong>當前的使用者資料庫</strong>下執行以下指令，就能確認該資料庫是否有啟用 TDE 加密：</p>



<p class="wp-block-paragraph">SQL</p>



<pre class="wp-block-code"><code>SELECT 
    db_name(database_id) AS DatabaseName,
    encryption_state,
    CASE encryption_state
        WHEN 0 THEN '無加密金鑰'
        WHEN 1 THEN '未加密 (Unencrypted)'
        WHEN 2 THEN '加密中 (Encryption in progress)'
        WHEN 3 THEN '已加密 (Encrypted)'
        WHEN 4 THEN '金鑰變更中'
        WHEN 5 THEN '解密中'
        ELSE '未知狀態'
    END AS EncryptionStateDescription
FROM sys.dm_database_encryption_keys;
</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4a1.png" alt="💡" class="wp-smiley" style="height: 1em; max-height: 1em;" /> <strong>預期結果：</strong> 如果你是使用 Azure SQL，通常會直接看到你的資料庫狀態為 <strong>3 (已加密 Encrypted)</strong>。</p>
</blockquote>



<h3 class="wp-block-heading">3. 如何在 Azure Portal 門戶上查看或修改？</h3>



<p class="wp-block-paragraph">除了用 SQL 指令，你也可以直接在網頁上點選確認：</p>



<ol start="1" class="wp-block-list">
<li>進入 <strong>Azure Portal</strong>，找到你的 <strong>SQL database</strong>。</li>



<li>在左側選單的「安全性 (Security)」分類下，點選 <strong>資料加密 (Data encryption)</strong>。</li>



<li>在這裡你可以看到 TDE 目前是 <strong>Enabled (已啟用)</strong>，也可以在這邊切換要使用「Azure 幫你管理的金鑰」還是「你自己的 Azure Key Vault 金鑰」。</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<figure class="wp-block-image size-large"><img decoding="async" width="1024" height="155" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/06/image-1024x155.png?v=1780469986" alt="" class="wp-image-8513" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/06/image-1024x155.png?v=1780469986 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/06/image-600x91.png?v=1780469986 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/06/image-768x116.png?v=1780469986 768w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/06/image.png?v=1780469986 1290w" sizes="(max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">這代表你的 <strong><code>tempdb</code></strong> 和 <strong><code>project-name-stg</code></strong> 資料庫目前都是 <strong>「3：已加密 (Encrypted)」</strong> 的狀態。</p>



<p class="wp-block-paragraph">在 Azure SQL 中，這是一個非常標準且健康的預設表現：</p>



<ul class="wp-block-list">
<li><strong><code>project-name-stg</code>（你的業務資料庫）</strong>：正如前面提到，Azure SQL 預設會為所有使用者資料庫啟用 TDE，確保你的資料在硬碟儲存時（Data at rest）是安全的。</li>



<li><strong><code>tempdb</code>（系統暫存資料庫）</strong>：只要你的實例中<strong>有任何一個</strong>使用者資料庫啟用了 TDE，Azure SQL 就會自動把 <code>tempdb</code> 也一起加密。這是為了防止敏感資料在運算過程中，被暫存到 <code>tempdb</code> 時留下未加密的軌跡。</li>
</ul>



<h3 class="wp-block-heading">接下來的建議</h3>



<p class="wp-block-paragraph">既然已經確認狀態為 <code>3 (Encrypted)</code>，代表<strong>透明資料加密已經在底層默默幫你守護資料了</strong>，你不需要再手動執行任何加密指令（例如 <code>ALTER DATABASE ... SET ENCRYPTION ON</code>）。</p>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/06/azure-sql-database-tde/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Azure 標準型或進階型的 「金鑰保險箱 (Key Vault)」</title>
		<link>https://stackoverflow.max-everyday.com/2026/05/azure-key-vault/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/05/azure-key-vault/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Wed, 27 May 2026 07:10:50 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[azure]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8478</guid>

					<description><![CDATA[這篇文章主要在說明如何正確尋找、使用並安全設定 ...]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">這篇文章主要在說明如何正確尋找、使用並安全設定 Azure 的「金鑰保險箱（ Key vaults ）」，並針對資料庫欄位加密的實際開發給予建議。</p>



<p class="wp-block-paragraph">以下為您摘要這篇文章解決的痛點以及對工程師的實質幫助：</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">解決那些事情</h2>



<ul class="wp-block-list">
<li><strong>找不到標準型與進階型保險箱：</strong> 許多人在 Azure Portal 輸入關鍵字後，只會看到高階的「受控 HSM 」，文章引導如何透過正確的關鍵字與認明「綠色鑰匙與抽屜」的圖示，順利找到標準型或進階型的服務。</li>



<li><strong>金鑰與祕密傻傻分不清：</strong> 釐清了 Key Vault 中「祕密（ Secrets ）」與「金鑰（ Keys ）」的核心差異。祕密適合存明文字串（如資料庫連接字串），而金鑰則是在內部進行密碼編譯操作且永遠不離開保險箱。</li>



<li><strong>環境變數設定與程式碼外洩風險：</strong> 傳統將 AES 金鑰寫死在程式碼或存放於實體 <code>.env</code> 檔案中，容易因為遺失、伺服器被駭或員工離職而外洩。文章提供改用 Azure SDK 搭配「受控識別（ Managed Identity ）」的架構，讓金鑰只存在於記憶體中。</li>



<li><strong>權限卡關與網路安全限制：</strong> 解決了在設定 RBAC 權限模型時不知道該選什麼角色的問題，並說明如何透過「網路功能」設定虛擬網路（ VNet ）或公用 IP 白名單，阻擋來自外網的惡意存取。</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">對工程師有何幫助</h2>



<h3 class="wp-block-heading">1. 提供明確的架構選擇題</h3>



<p class="wp-block-paragraph">針對要實作資料庫欄位加密的工程師，文章給出兩種清晰的方案：</p>



<ul class="wp-block-list">
<li>如果是用資料庫內建的 Always Encrypted ，應選擇 <strong>金鑰（ Keys ）</strong> 來當作主加密金鑰（ CMK ）。</li>



<li>如果是工程師自己寫程式實作 AES-256 加解密，應將產生的 Base64 字串存入 <strong>祕密（ Secrets ）</strong> 。</li>
</ul>



<h3 class="wp-block-heading">2. 附帶實用的程式碼範例</h3>



<p class="wp-block-paragraph">文章直接提供了 Python 與 Go 語言的實作腳本，包含如何產生安全的隨機金鑰、如何使用 AES-GCM 演算法進行加解密，以及如何利用官方 SDK 在容器啟動時動態撈取金鑰。這讓工程師可以直接複製修改，加速開發。</p>



<h3 class="wp-block-heading">3. 省錢與資安的觀念建立</h3>



<ul class="wp-block-list">
<li><strong>資安防護：</strong> 提醒工程師開啟「軟刪除」與「清除保護」，防止金鑰被誤刪或惡意刪除。</li>



<li><strong>節省成本：</strong> 提醒工程師只需要在「程式啟動時」呼叫一次 Key Vault ，後續運算都在記憶體執行，避免每次解密都連線而產生高額的交易費用。</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">直接輸入Azure Key Vault 只看的到高級的版本：</p>


<div class="wp-block-image">
<figure class="aligncenter size-large"><img decoding="async" width="501" height="1024" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/05/2662d70c-b2db-42b8-9ac3-6cd28427140c-501x1024.jpg?v=1779860972" alt="" class="wp-image-8479" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/05/2662d70c-b2db-42b8-9ac3-6cd28427140c-501x1024.jpg?v=1779860972 501w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/05/2662d70c-b2db-42b8-9ac3-6cd28427140c-294x600.jpg?v=1779860972 294w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/05/2662d70c-b2db-42b8-9ac3-6cd28427140c-768x1570.jpg?v=1779860972 768w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/05/2662d70c-b2db-42b8-9ac3-6cd28427140c-751x1536.jpg?v=1779860972 751w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/05/2662d70c-b2db-42b8-9ac3-6cd28427140c.jpg?v=1779860972 816w" sizes="(max-width: 501px) 100vw, 501px" /></figure>
</div>


<p class="wp-block-paragraph"><strong>受控 HSM (Managed HSM)</strong> 與一般的 <strong>金鑰保險箱 (Key Vault)</strong> 在 Azure 中是兩個不同的資源類型：</p>



<ol start="1" class="wp-block-list">
<li><strong>如果您本來就打算使用最高安全層級、完全專屬的硬體裝置</strong>： 那沒錯，就是這裡！因為您目前帳號下還沒有建立任何受控 HSM，請直接點擊畫面中央或右下角的 <strong>「藍色 ＋ 建立」</strong> 按鈕，來建立一個全新的受控 HSM 執行個體。建立完成後，才能進入內部設定金鑰與權限。</li>



<li><strong>如果您之前已經有建立過金鑰儲存資源，但在這個畫面看不到</strong>： 那代表您之前建立的是標準型或進階型的 <strong>「金鑰保險箱 (Key Vault)」</strong>，而不是受控 HSM。
<ul class="wp-block-list">
<li><strong>如何切換：</strong> 請看您截圖的最右上方，有一個長條形的「搜尋資源、服務及文件 (G+/)」 搜尋列。請直接在裡面輸入您的金鑰儲存庫名稱，或是輸入 <code>金鑰保險箱</code> (Key vaults)，就能找到正確的資源頁面。</li>
</ul>
</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">要找到標準型（Standard）或進階型（Premium）的 <strong>「金鑰保險箱 (Key Vault)」</strong>，操作非常簡單。</p>



<p class="wp-block-paragraph">請看您剛才搜尋畫面的同一個搜尋列，依照以下步驟輸入：</p>



<h3 class="wp-block-heading">步驟一：在搜尋列輸入正確關鍵字</h3>



<p class="wp-block-paragraph">請在最上方的藍色搜尋列中，清除原本的字，改輸入以下其中一個關鍵字：</p>



<ul class="wp-block-list">
<li>中文：<strong>金鑰保險箱</strong></li>



<li>英文：<strong>Key vaults</strong> <em>(注意：後面<strong>不要</strong>加上「受控 HSM」)</em></li>
</ul>



<h3 class="wp-block-heading">步驟二：認明正確的圖示與服務</h3>



<p class="wp-block-paragraph">當您輸入後，下方會出現搜尋結果。請認明以下圖示與文字：</p>



<ol start="1" class="wp-block-list">
<li><strong>圖示（Icon）：</strong> 是一個「綠色的鑰匙，背景是一個抽屜/保險箱」的圖案（如下圖所示）。</li>



<li><strong>文字：</strong> 服務名稱只會寫 <strong>「金鑰保險箱」</strong> 或 <strong>「Key vaults」</strong>。</li>
</ol>



<h3 class="wp-block-heading">步驟三：進入並點擊建立</h3>



<ol start="1" class="wp-block-list">
<li>點擊該「金鑰保險箱」服務進入。</li>



<li>進入後，點擊畫面左上角的 <strong>「＋ 建立」</strong>（或 Create）。</li>



<li>在建立的設定畫面中，有一欄叫做「定價層 (Pricing tier)」，您就可以在該處下拉選單自由選擇：
<ul class="wp-block-list">
<li><strong>標準 (Standard)：</strong> 使用軟體保護金鑰。</li>



<li><strong>進階 (Premium)：</strong> 使用符合 FIPS 140 的 HSM（硬體安全模組）保護金鑰。</li>
</ul>
</li>
</ol>



<p class="wp-block-paragraph">這兩者都支援您一開始提到的資料庫欄位加密（例如 Always Encrypted 的主金鑰 CMK 管理），且功能與設定介面完全一樣，您可以依預算與合規需求選擇！</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">在 Azure Key Vault 的語境中，「祕密 (Secrets)」<strong>與</strong>「金鑰 (Keys)」是兩種完全不同的資源類型，其核心加密機制與背後使用的硬體基礎也有很大的差異。</p>



<p class="wp-block-paragraph">以下為您詳細解析兩者的差異、使用時機，以及針對您要存放 <strong>Database Column AES Key（資料庫欄位加密金鑰）</strong> 該如何選擇。</p>



<h3 class="wp-block-heading">一、 「祕密 (Secrets)」 vs 「金鑰 (Keys)」 的核心差異</h3>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><td><strong>特性</strong></td><td><strong>祕密 (Secrets) <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f512.png" alt="🔒" class="wp-smiley" style="height: 1em; max-height: 1em;" /></strong></td><td><strong>金鑰 (Keys) <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f511.png" alt="🔑" class="wp-smiley" style="height: 1em; max-height: 1em;" /></strong></td></tr></thead><tbody><tr><td><strong>本質是什麼</strong></td><td><strong>純文字字串（Data 內容）</strong>。長度上限為 25KB，您可以把任何文字（如密碼、連接字串、API Key）塞進去。</td><td><strong>密碼編譯金鑰（Cryptographic Key）</strong>。由特定演算法（如 RSA、EC）產生的非對稱金鑰。</td></tr><tr><td><strong>運作機制</strong></td><td>當應用程式跟 Key Vault 要祕密時，Key Vault 會<strong>把明文字串直接回傳</strong>給應用程式，由應用程式自己拿去用。</td><td>金鑰<strong>永遠不會離開</strong> Key Vault（特別是 HSM 保護的金鑰）。加解密運算是<strong>在 Key Vault 內部（或 HSM 內）完成</strong>，再將結果回傳。</td></tr><tr><td><strong>主要功能</strong></td><td>純粹的安全儲存與讀取（Get/List）。</td><td>密碼編譯操作（Sign/Verify, Wrap/Unwrap, Encrypt/Decrypt）。</td></tr><tr><td><strong>硬體防護 (HSM)</strong></td><td>僅能由軟體提供安全防護與加密。</td><td>在「進階層」或「受控 HSM」中，金鑰生成與運算完全在 <strong>HSM 硬體</strong>內進行，連 Azure 系統管理員也無法導出。</td></tr></tbody></table></figure>



<h3 class="wp-block-heading">二、 什麼時候用「祕密」？什麼時候用「金鑰」？</h3>



<h4 class="wp-block-heading"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4a1.png" alt="💡" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 使用「祕密 (Secrets)」的時機：</h4>



<ul class="wp-block-list">
<li>存放資料庫連接字串（Connection String，例如：<code>Server=tcp:sqlserver...;User ID=myadmin;Password=...</code>）。</li>



<li>存放第三方 API Token 或認證私鑰（如 SendGrid API Key、LINE Bot Token）。</li>



<li>存放一般的帳號密碼。</li>
</ul>



<h4 class="wp-block-heading"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4a1.png" alt="💡" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 使用「金鑰 (Keys)」的時機：</h4>



<ul class="wp-block-list">
<li>用於資料磁盘加密（Azure Disk Encryption）或儲存體加密（BYOK &#8211; Bring Your Own Key）。</li>



<li>需要高規格的密碼學合規需求（如 FIPS 140 驗證）。</li>



<li><strong>用於保護、加密其他資料加密金鑰（如金鑰信封機制 Envelope Encryption）。</strong></li>
</ul>



<h3 class="wp-block-heading">三、 結論：要存放 Database Column AES Key，該選哪一個？</h3>



<p class="wp-block-paragraph">針對您提到的 <strong>Database Column AES Key（對稱式加解密金鑰）</strong>，選擇的關鍵取決於您的<strong>加密實作架構</strong>：</p>



<h4 class="wp-block-heading">方案 A：如果您使用的是資料庫內建的 Always Encrypted（始終加密）功能（強烈推薦 <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2b50.png" alt="⭐" class="wp-smiley" style="height: 1em; max-height: 1em;" />）</h4>



<ul class="wp-block-list">
<li><strong>請選擇 ➔ 金鑰 (Keys)</strong></li>



<li><strong>原因：</strong> 在 Azure SQL / SQL Server 的 Always Encrypted 機制中，欄位是用 <strong>DEK（資料加密金鑰，一組 AES-256 金鑰）</strong> 加密的。而這把 DEK 必須由一把 <strong>CMK（主加密金鑰）</strong> 來進行保護（Wrap/Unwrap）。</li>



<li><strong>如何實作：</strong> 您應該在 Azure Key Vault 的 <strong>「金鑰 (Keys)」</strong> 中建立一把 RSA 金鑰作為 CMK。SQL Server 驅動程式會自動呼叫 Key Vault 的金鑰來解密 DEK，整個過程中，應用程式和工程師都碰不到最核心的主金鑰。</li>
</ul>



<h4 class="wp-block-heading">方案 B：如果是您自己的應用程式（程式碼）要自行實作 AES-256 加解密</h4>



<ul class="wp-block-list">
<li><strong>請選擇 ➔ 祕密 (Secrets)</strong></li>



<li><strong>原因：</strong> 因為 Azure Key Vault 的「金鑰 (Keys)」目前<strong>原生只提供非對稱式金鑰（如 RSA、EC）</strong>，並不直接提供純對稱式 AES 金鑰的儲存與應用程式端直接讀取。</li>



<li><strong>如何實作：</strong> 您在自己的電腦或程式中產生一組 AES-256 的 Key（通常是一串 Base64 編碼的字串），然後將這串字串當作文字，存入 Azure Key Vault 的 <strong>「祕密 (Secrets)」</strong> 中。當程式需要加解密欄位時，先去 Key Vault 把這個「祕密」抓回到應用程式的記憶體中，再在程式碼裡用 AES 演算法去解密資料庫欄位。</li>
</ul>



<h3 class="wp-block-heading">總結建議</h3>



<ol start="1" class="wp-block-list">
<li>如果可以，請採用 <strong>Azure SQL / SQL Server 的 Always Encrypted</strong> 機制，並在 Azure Key Vault 中建立 <strong>「金鑰 (Keys)」</strong> 來當作主金鑰。這是業界最標準且最安全的資料庫欄位加密做法。</li>



<li>如果是傳統程式碼（例如自寫的 Python/C# Encrypt class）需要一把固定密碼來做 AES 欄位加解密，請將該密碼存入 <strong>「祕密 (Secrets)」</strong>。</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">這裡為您提供一份 Python 腳本。這個腳本會示範如何遵循資訊安全最佳實踐，產生一組安全的 <strong>AES-256 金鑰</strong>（使用加密等級的隨機亂數，而非一般隨機），並將其轉換為最適合存入 Azure Key Vault「祕密 (Secrets)」的 <strong>Base64 編碼字串</strong>。</p>



<p class="wp-block-paragraph">此外，腳本中也附帶了如何使用這把金鑰進行欄位加解密的範例。</p>



<h3 class="wp-block-heading">事前準備</h3>



<p class="wp-block-paragraph">在執行腳本前，請確保您的環境已安裝 <code>cryptography</code> 套件（這是目前 Python 最推薦、最安全的密碼學函式庫）：</p>



<p class="wp-block-paragraph">Bash</p>



<pre class="wp-block-code"><code>pip install cryptography
</code></pre>



<h3 class="wp-block-heading">Python 實作腳本</h3>



<p class="wp-block-paragraph">Python</p>



<pre class="wp-block-code"><code>import base64
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

def generate_aes_256_key_for_azure_secret():
    """
    產生一組符合 AES-256 要求（32 位元組 / 256 位元）的安全隨機金鑰，
    並將其編碼為 Base64 字串，方便複製並貼上到 Azure Key Vault 的「祕密」中。
    """
    # 1. 使用作業系統等級的安全隨機來源產生 32 bytes (256 bits) 的密鑰資料
    raw_key = os.urandom(32)
    
    # 2. 轉換為 Base64 編碼的文字字串，這樣才能以「純文字」存入 Azure Secret
    base64_encoded_key = base64.b64encode(raw_key).decode('utf-8')
    
    return base64_encoded_key


# ==========================================
# 示範：如何使用這把金鑰在程式中加解密資料庫欄位
# ==========================================
def encrypt_column_data(plain_text: str, base64_key: str) -&gt; str:
    """使用 AES-GCM 演算法加密資料庫欄位 (安全且含完整性校驗)"""
    # 從 Base64 還原回原始的 bytes 金鑰
    key_bytes = base64.b64decode(base64_key.encode('utf-8'))
    aesgcm = AESGCM(key_bytes)
    
    # 產生一個唯一的 nonce (初始化向量)，每次加密都必須不同
    nonce = os.urandom(12) 
    
    # 加密資料
    encrypted_bytes = aesgcm.encrypt(nonce, plain_text.encode('utf-8'), None)
    
    # 將 nonce 和加密後的資料組合，並轉為 Base64 方便存入資料庫的 VARCHAR 或 TEXT 欄位
    final_data = nonce + encrypted_bytes
    return base64.b64encode(final_data).decode('utf-8')


def decrypt_column_data(cipher_text: str, base64_key: str) -&gt; str:
    """解密資料庫欄位"""
    key_bytes = base64.b64decode(base64_key.encode('utf-8'))
    aesgcm = AESGCM(key_bytes)
    
    # 還原資料庫中的密文
    raw_data = base64.b64decode(cipher_text.encode('utf-8'))
    
    # 拆分出前 12 位元組的 nonce 和後面的密文
    nonce = raw_data&#91;:12]
    encrypted_bytes = raw_data&#91;12:]
    
    # 解密
    decrypted_bytes = aesgcm.decrypt(nonce, encrypted_bytes, None)
    return decrypted_bytes.decode('utf-8')


# --- 執行測試 ---
if __name__ == "__main__":
    print("--- 1. 開始產生 AES-256 金鑰 ---")
    my_secret_key = generate_aes_256_key_for_azure_secret()
    
    print(f"【請複製以下這串文字，存入 Azure Key Vault 的『祕密』中】:")
    print(f"==&gt; {my_secret_key}\n")
    
    print("--- 2. 模擬資料庫欄位加解密測試 ---")
    sensitive_data = "850101-1234567" # 假設這是敏感的身分證字號或信用卡號
    print(f"原始欄位明文: {sensitive_data}")
    
    # 加密
    encrypted_db_column = encrypt_column_data(sensitive_data, my_secret_key)
    print(f"寫入資料庫的加密密文: {encrypted_db_column}")
    
    # 解密
    decrypted_db_column = decrypt_column_data(encrypted_db_column, my_secret_key)
    print(f"從資料庫取出並解密後的明文: {decrypted_db_column}")
</code></pre>



<h3 class="wp-block-heading"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4a1.png" alt="💡" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 接下來在 Azure 的實際操作流程</h3>



<ol start="1" class="wp-block-list">
<li>執行上方腳本，終端機會印出一串類似 <code>rXg7B...=</code> 的 <strong>Base64 字串</strong>。</li>



<li>登入 Azure Portal，進入您的 <strong>金鑰保險箱 (Key Vault)</strong>。</li>



<li>在左側選單中點選 <strong>「祕密 (Secrets)」</strong> ➔ 點擊上方的 <strong>「＋ 產生/匯入」</strong>。</li>



<li><strong>設定參數：</strong>
<ul class="wp-block-list">
<li><strong>上傳選項：</strong> 手動</li>



<li><strong>名稱：</strong> 例如 <code>DbColumnEncryptionKey</code></li>



<li><strong>祕密值：</strong> <strong>把剛剛 Python 產生的那串 Base64 字串貼進來</strong>。</li>
</ul>
</li>



<li>點擊建立。之後您的應用程式（例如部署在 Azure App Service 或 VM 上）只要透過受控識別（Managed Identity）去撈取這個名為 <code>DbColumnEncryptionKey</code> 的祕密，就能直接套用在程式中的加解密邏輯了。</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph"><strong>是的，非常正確！強烈建議將這把金鑰放入 Azure Key Vault。</strong></p>



<p class="wp-block-paragraph">如果把 <code>AES_KEY</code> 寫死在程式碼、或是以明文寫在伺服器上的 <code>.env</code> 檔案中，一旦伺服器被駭、程式碼上傳到公開的 GitHub、或是離職員工外洩，資料庫的所有加密欄位就會被全部看光。此外，伺服器硬碟損壞也可能導致 <code>.env</code> 檔案永久遺失。</p>



<p class="wp-block-paragraph">將金鑰放入 Azure Key Vault (AKV) 可以同時解決「安全保護」<strong>與</strong>「防止遺失」兩大問題。以下為您說明為什麼該放，以及最安全、最標準的架構該怎麼做：</p>



<h3 class="wp-block-heading">1. 放在 Key Vault 為什麼能「防遺失」？</h3>



<p class="wp-block-paragraph">在 Azure Key Vault 中，您可以開啟以下兩個核心安全開關（通常在建立 Key Vault 時就預設開啟了）：</p>



<ul class="wp-block-list">
<li><strong>軟刪除 (Soft-delete)：</strong> 就算有人（或您自己）不小心點到刪除金鑰，金鑰不會立刻消失，而是會進入資源回收桶，在保留期內（預設 90 天）都可以一鍵復原。</li>



<li><strong>清除保護 (Purge Protection)：</strong> 開啟後，任何人（包括最高管理員）都<strong>無法強行永久刪除</strong>回收桶裡的金鑰，必須等到保留天數過期，這能徹底防範惡意員工報復性刪除金鑰。</li>
</ul>



<h3 class="wp-block-heading">2. 進階安全做法：Go 語言 Container 該如何安全讀取 Key Vault？</h3>



<p class="wp-block-paragraph">既然要把金鑰放進 Key Vault，您的 Container 程式就不應該在啟動時透過 <code>.env</code> 檔案傳入金鑰，因為那樣只是把風險轉移到 <code>.env</code> 檔案上。</p>



<p class="wp-block-paragraph"><strong>最標準的雲端安全架構流程如下：</strong></p>



<pre class="wp-block-code"><code>&#91;Azure Container / 您的程式]
       │
       ├─ 1. 使用「受控識別 (Managed Identity)」向 Azure 證明自己是合法會程式
       │
       ├─ 2. 透過 Azure SDK 直接呼叫 Key Vault 
       ▼
&#91;Azure Key Vault] ──(確認權限)──&gt; 3. 將金鑰安全地回傳到 Go 程式的記憶體中
</code></pre>



<h4 class="wp-block-heading">實作步驟：</h4>



<h5 class="wp-block-heading">步驟一：在 Go 程式中引入 Azure SDK</h5>



<p class="wp-block-paragraph">Go 語言有官方維護的 Azure SDK，可以讓程式直接安全地跟 Key Vault 溝通：</p>



<p class="wp-block-paragraph">Bash</p>



<pre class="wp-block-code"><code>go get github.com/Azure/azure-sdk-for-go/sdk/azidentity
go get github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets
</code></pre>



<h5 class="wp-block-heading">步驟二：Go 程式碼範例</h5>



<p class="wp-block-paragraph">您的 Go 語言程式可以直接改寫成這樣，在啟動時自動去 Key Vault 抓取變數：</p>



<p class="wp-block-paragraph">Go</p>



<pre class="wp-block-code"><code>package main

import (
	"context"
	"fmt"
	"log"
	"os"

	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
	"github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets"
)

func main() {
	// 1. 設定您的 Key Vault 網址 (這可以放在環境變數中，這個網址公開也沒關係)
	vaultURL := "https://kv-pr-stg-jpe-001.vault.azure.net/"

	// 2. 使用 DefaultAzureCredential
	// 它會自動偵測環境。在本地會讀您的 Azure CLI 登入身分；在雲端會自動使用 Container 的 Managed Identity
	cred, err := azidentity.NewDefaultAzureCredential(nil)
	if err != nil {
		log.Fatalf("無法取得憑證: %v", err)
	}

	// 3. 建立 Key Vault 祕密用戶端
	client, err := azsecrets.NewClient(vaultURL, cred, nil)
	if err != nil {
		log.Fatalf("無法建立 Key Vault 用戶端: %v", err)
	}

	// 4. 從 Key Vault 撈取您的 AES 金鑰
	secretName := "projectname-database-column-aes-key"
	resp, err := client.GetSecret(context.TODO(), secretName, "", nil)
	if err != nil {
		log.Fatalf("撈取祕密失敗 (請檢查上一輪的 IAM 權限是否指派正確): %v", err)
	}

	// 5. 這就是您的 AES-256 金鑰（Base64 字串）
	aesKey := *resp.Value
	fmt.Printf("成功安全地下載金鑰！長度為: %d\n", len(aesKey))

	// 接下來就可以拿這把 aesKey 去做資料庫欄位加解密了...
}
</code></pre>



<h3 class="wp-block-heading">總結</h3>



<ol start="1" class="wp-block-list">
<li><strong>立刻把金鑰放到 Key Vault 的「祕密 (Secrets)」中。</strong></li>



<li>確保在 Key Vault 的「存取設定」中開啟了 <strong>軟刪除</strong> 與 <strong>清除保護</strong>。</li>



<li>容器（Container）內<strong>不需要存任何 <code>.env</code> 金鑰檔案</strong>，改用 <strong>Azure SDK (DefaultAzureCredential)</strong> 讓程式在啟動時動態去 Key Vault 撈取。</li>
</ol>



<p class="wp-block-paragraph">這樣做，您的金鑰就絕對不會遺失，也達到了企業級的資安標準！</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">這裡為您提供 <strong>Python 版本</strong>的腳本。這個腳本遵循與前面相同的安全架構：</p>



<p class="wp-block-paragraph">容器（Container）內<strong>完全不需要攜帶任何 <code>.env</code> 檔案</strong>。Python 程式在啟動時，會透過 Azure 官方 SDK 搭配 <strong>受控識別 (Managed Identity)</strong>，自動且安全地向 <code>kv-pr-stg-jpe-001</code> 撈取金鑰並直接載入記憶體中使用。</p>



<h3 class="wp-block-heading"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f40d.png" alt="🐍" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 事前準備</h3>



<p class="wp-block-paragraph">在您的 Dockerfile 或本地環境中，請安裝 Azure Key Vault 與身分驗證所需的官方套件：</p>



<p class="wp-block-paragraph">Bash</p>



<pre class="wp-block-code"><code>pip install azure-identity azure-keyvault-secrets cryptography
</code></pre>



<h3 class="wp-block-heading">Python 實作腳本</h3>



<p class="wp-block-paragraph">Python</p>



<pre class="wp-block-code"><code>import base64
import os
import sys
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

def fetch_aes_key_from_key_vault():
    """
    使用 Azure SDK 安全地從 Key Vault 撈取儲存的 AES-256 金鑰 (Base64 字串)
    """
    # 1. 定閱您的 Key Vault 網址 (這個網址公開在程式碼裡是安全的)
    vault_url = "https://kv-pr-stg-jpe-001.vault.azure.net/"
    
    # 2. 使用 DefaultAzureCredential 
    # 本地開發時：會自動讀取您電腦上 Azure CLI 登入的身分
    # 雲端 Container 環境：會自動使用該容器配給的 Managed Identity 身分
    credential = DefaultAzureCredential()
    
    try:
        # 3. 建立 Key Vault 祕密用戶端
        client = SecretClient(vault_url=vault_url, credential=credential)
        
        # 4. 撈取您在上一階段設定的祕密
        secret_name = "projectname-database-column-aes-key"
        retrieved_secret = client.get_secret(secret_name)
        
        print("Successfully connected to Azure Key Vault.")
        return retrieved_secret.value

    except Exception as e:
        print(f"Error fetching secret from Key Vault: {e}", file=sys.stderr)
        print("請檢查您上一輪在 Key Vault 的 IAM 權限中，是否有正確將 'Key Vault Secrets Officer' 指派給該身分。", file=sys.stderr)
        sys.exit(1)


# ==========================================
# 密碼學核心：使用撈取到的金鑰進行加解密
# ==========================================
def encrypt_column_data(plain_text: str, base64_key: str) -> str:
    """使用 AES-GCM 演算法加密資料庫欄位"""
    # 還原原始 bytes 金鑰
    key_bytes = base64.b64decode(base64_key.encode('utf-8'))
    aesgcm = AESGCM(key_bytes)
    
    # 產生唯一的隨機 12-byte nonce
    nonce = os.urandom(12) 
    
    # 加密
    encrypted_bytes = aesgcm.encrypt(nonce, plain_text.encode('utf-8'), None)
    
    # 組合並轉為 Base64 字串以存入資料庫
    final_data = nonce + encrypted_bytes
    return base64.b64encode(final_data).decode('utf-8')


def decrypt_column_data(cipher_text: str, base64_key: str) -> str:
    """解密從資料庫取出的密文欄位"""
    key_bytes = base64.b64decode(base64_key.encode('utf-8'))
    aesgcm = AESGCM(key_bytes)
    
    # 解開資料庫的 Base64 密文
    raw_data = base64.b64decode(cipher_text.encode('utf-8'))
    
    # 拆分前 12 bytes 的 nonce 與後續密文
    nonce = raw_data&#91;:12]
    encrypted_bytes = raw_data&#91;12:]
    
    # 解密
    decrypted_bytes = aesgcm.decrypt(nonce, encrypted_bytes, None)
    return decrypted_bytes.decode('utf-8')


# --- 程式進入點 ---
if __name__ == "__main__":
    print("=== &#91;1] 正在向 Azure Key Vault 請求 AES 金鑰... ===")
    # 動態從雲端撈取金鑰，不留任何痕跡在實體硬碟上
    db_aes_key = fetch_aes_key_from_key_vault()
    print(f"【成功】已將金鑰安全載入記憶體。金鑰長度為: {len(db_aes_key)} 字元\n")
    
    print("=== &#91;2] 模擬資料庫欄位加解密測試 ===")
    sensitive_data = "projectname-user-ssn-123456"
    print(f"原始敏感資料: {sensitive_data}")
    
    # 測試加密
    encrypted_column = encrypt_column_data(sensitive_data, db_aes_key)
    print(f"準備寫入 DB 的加密密文: {encrypted_column}")
    
    # 測試解密
    decrypted_column = decrypt_column_data(encrypted_column, db_aes_key)
    print(f"從 DB 取出並成功解密明文: {decrypted_column}")
</code></pre>



<h3 class="wp-block-heading"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f433.png" alt="🐳" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 如何在 Docker 環境中執行它？</h3>



<ol start="1" class="wp-block-list">
<li><strong>本地測試：</strong>在您的個人電腦上，只要確保您的終端機已經執行過 <code>az login</code> 登入，這個 Python 腳本就能直接在本地執行，它會利用您的個人憑證去 Key Vault 撈取金鑰。</li>



<li><strong>部署到 Azure Container 環境（如 App Service / Container Apps）：</strong>
<ul class="wp-block-list">
<li>您不需要在容器內設定環境變數 <code>projectname-database-column-aes-key</code>。</li>



<li>您只需要在 Azure 控制台上，為該容器服務開啟 <strong>「受控識別 (Managed Identity)」</strong>。</li>



<li>回到 Key Vault 資源中，將 <strong>「Key Vault Secrets Officer」</strong> 或 <strong>「Key Vault Secrets User」</strong> 的角色指派給該容器的身分。</li>



<li>容器啟動後，官方 SDK (<code>DefaultAzureCredential</code>) 會自動完成所有驗證，無縫接軌！</li>
</ul>
</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">要在您位於日本東部的金鑰保險箱 <code>kv-pr-stg-jpe-001</code> 設定 Container（容器）的白名單，標準的做法是透過 <strong>Azure Key Vault 的「網路安全性 (Networking)」</strong> 功能。</p>



<p class="wp-block-paragraph">這樣可以確保<strong>只有來自您指定 Container 所在網路的流量</strong>才能存取這台 Key Vault，其他網際網路上的存取（即使有正確的帳號密碼或認證）都會被直接封鎖。</p>



<p class="wp-block-paragraph">設定方式取決於您的 Container 是部署在 Azure 的哪種服務中。以下為您說明兩種最常見情境的設定步驟：</p>



<h3 class="wp-block-heading">情境 A：您的 Container 部署在 Azure 虛擬網路 (VNet) 內</h3>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><em>適用於：Azure Container Apps、AKS (Kubernetes)、Azure App Service (已啟用 VNet 整合) 或 Azure VM 上的 Docker。</em></p>
</blockquote>



<p class="wp-block-paragraph">這是最安全的作法，將 Key Vault 的存取權限制在特定的虛擬網路子網（Subnet）中：</p>



<h4 class="wp-block-heading">步驟 1：前往網路設定頁面</h4>



<ol start="1" class="wp-block-list">
<li>在 Azure Portal 進入您的金鑰保險箱 <code>kv-pr-stg-jpe-001</code>。</li>



<li>在左側選單中，找到「設定」分類下的 <strong>「網路功能 (Networking)」</strong>。</li>
</ol>



<h4 class="wp-block-heading">步驟 2：切換為允許選取的網路</h4>



<ol start="1" class="wp-block-list">
<li>在「防火牆與虛擬網路」標籤頁中，將「允許自以下位置存取:」從「所有網路」改選為 <strong>「選取的網路 (Selected networks)」</strong>。</li>
</ol>



<h4 class="wp-block-heading">步驟 3：加您的 Container 子網路加入白名單</h4>



<ol start="1" class="wp-block-list">
<li>在下方的「虛擬網路」區塊中，點擊 <strong>「+ 新增現有的虛擬網路 (Add existing virtual network)」</strong>。</li>



<li>選擇您的 Container 所在的 <strong>訂閱</strong>、<strong>虛擬網路 (VNet)</strong> 以及特定的 <strong>子網路 (Subnet)</strong>。</li>



<li>點擊頁面最下方的 <strong>「儲存 (Save)」</strong>。</li>
</ol>



<h3 class="wp-block-heading">情境 B：您的 Container 具有固定的公用 IP (Public IP)</h3>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><em>適用於：您的 Container 部署在公司地端機房、其他雲端（如 AWS），或是 Azure 上帶有固定輸出的公用 IP 服務。</em></p>
</blockquote>



<p class="wp-block-paragraph">如果 Container 無法直接透過 Azure 內網連接，就必須透過公用 IP 防火牆白名單來管控：</p>



<h4 class="wp-block-heading">步驟 1 與 2：同上</h4>



<p class="wp-block-paragraph">進入 <code>kv-pr-stg-jpe-001</code> ➔ <strong>「網路功能」</strong> ➔ 改選為 <strong>「選取的網路」</strong>。</p>



<h4 class="wp-block-heading">步驟 2：填入防火牆 IP 白名單</h4>



<ol start="1" class="wp-block-list">
<li>在下方的 <strong>「防火牆 (Firewall)」</strong> 區塊中。</li>



<li>在「位址或位址範圍」欄位，輸入您 <strong>Container 伺服器的對外公用 IP 位址</strong>（例如：<code>210.61.xx.xx</code> 或以 CIDR 格式表示如 <code>210.61.xx.xx/32</code>）。</li>



<li>點擊頁面最下方的 <strong>「儲存 (Save)」</strong>。</li>
</ol>



<h3 class="wp-block-heading"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 極度重要的資安注意事項與陷阱</h3>



<ol start="1" class="wp-block-list">
<li><strong>不要勾選「允許受信任的 Microsoft 服務&#8230;」嗎？</strong>在網路安全性頁面的最下方，通常有一個「例外狀況」勾選框：<strong>「允許受信任的 Microsoft 服務略過此防火牆」</strong>。
<ul class="wp-block-list">
<li><strong>強烈建議勾選。</strong> 如果您未來有使用 Azure 內建的備份、Azure Monitor 診斷日誌，或是其他 Azure 核心服務要存取這台 Key Vault，勾選此項可以確保這些微軟內部信任的流量不會被硬生生擋掉。</li>
</ul>
</li>



<li><strong>本地開發電腦會斷線：</strong>當您一旦將網路改為「選取的網路」後，<strong>您自己的電腦也會立刻無法存取這台 Key Vault 的祕密</strong>（您在 Portal 網頁上看祕密會顯示拒絕存取，本地執行 Python 測試也會報錯）。
<ul class="wp-block-list">
<li><strong>解決方法：</strong> 在設定 Container 白名單的同時，記得在「防火牆」區塊中，勾選 <strong>「新增您的目前用戶端 IP 位址」</strong>，把您當下辦公室或家里的對外 IP 也一起暫時加進白名單，這樣您才能繼續進行開發與測試。</li>
</ul>
</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="1953" height="966" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/05/chrome_2026-05-27-15-05-8s.jpg?v=1779866305" alt="" class="wp-image-8483" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/05/chrome_2026-05-27-15-05-8s.jpg?v=1779866305 1953w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/05/chrome_2026-05-27-15-05-8s-600x297.jpg?v=1779866305 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/05/chrome_2026-05-27-15-05-8s-1024x506.jpg?v=1779866305 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/05/chrome_2026-05-27-15-05-8s-768x380.jpg?v=1779866305 768w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/05/chrome_2026-05-27-15-05-8s-1536x760.jpg?v=1779866305 1536w" sizes="auto, (max-width: 1953px) 100vw, 1953px" /></figure>



<p class="wp-block-paragraph">看到您的截圖了！您的 Key Vault 目前選用的是 <strong>「Azure 角色型存取控制 (Azure RBAC)」</strong> 權限模型（畫面上藍色勾選的推薦項目）。</p>



<p class="wp-block-paragraph"><mark style="background-color:rgba(0, 0, 0, 0)" class="has-inline-color has-vivid-red-color">使用&#8221;推薦項目&#8221;似乎會一直卡關, 建議使用第二個選項 &#8220;保存庫存取原則&#8221;.</mark></p>



<p class="wp-block-paragraph">在這個畫面上，您<strong>不需要勾選任何下方的「資源存取」選項</strong>（那些是給特定虛擬機器部署或磁碟加密用的，與您的資料庫欄位加密無關）。</p>



<p class="wp-block-paragraph">請直接點擊畫面中央那顆藍色的按鈕：<strong>「前往存取控制 (IAM)」</strong>，進去裡面指派角色。</p>



<p class="wp-block-paragraph">以下是點擊進去後的<strong>詳細操作步驟</strong>：</p>



<h3 class="wp-block-heading">步驟 1：新增角色指派</h3>



<p class="wp-block-paragraph">進入「存取控制 (IAM)」頁面後，點擊畫面上方的 <strong>「+ 新增 (Add)」</strong> ➔ 選擇 <strong>「新增角色指派 (Add role assignment)」</strong>。</p>



<h3 class="wp-block-heading">步驟 2：選擇「祕密主管」角色</h3>



<p class="wp-block-paragraph">在角色清單中，因為您要上傳（Set）AES 金鑰到「祕密」分類中，請搜尋並勾選以下角色：</p>



<ul class="wp-block-list">
<li><strong><code>Key Vault Secrets Officer</code></strong>（中文顯示為：<strong>金鑰保險箱祕密主管</strong>）</li>



<li><em>(注意：不要選到 Key Vault Secrets User，因為 User 只有讀取權限，無法執行寫入/SetSecret)</em></li>



<li>選好後點擊 <strong>「下一步」</strong>。</li>
</ul>



<h3 class="wp-block-heading">步驟 3：選取您的應用程式身分 (Object ID)</h3>



<ol start="1" class="wp-block-list">
<li>在「成員 (Members)」標籤頁中，將「指派存取權給」保持為 <strong>「使用者、群組或服務主體」</strong>。</li>



<li>點擊藍色的 <strong>「+ 選取成員 (+ Select members)」</strong>。</li>



<li>在右側跳出的搜尋欄中，直接黏貼您之前錯誤訊息中的 Object ID：<strong><code>7a85725f-a661-4489-baed-944f1ff241ca</code></strong></li>



<li>點擊下方搜尋出來的應用程式，讓它顯示在「選取的成員」列表中，然後點擊「選取」。</li>
</ol>



<h3 class="wp-block-heading">步驟 4：檢閱並指派</h3>



<p class="wp-block-paragraph">一路點擊 <strong>「下一步」</strong> 直到最後一頁，點擊 <strong>「檢閱並指派 (Review + assign)」</strong>。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph"><strong>是的，存取 Azure Key Vault 需要花費費用。</strong> 不過別擔心，Key Vault 的計費方式非常特別，它是「以量計價」<strong>，而且</strong>價格極其便宜。對於一般中小型應用程式或資料庫欄位加解密來說，每個月的花費通常<strong>不到幾塊錢台幣（甚至不到 1 元）</strong>。</p>



<p class="wp-block-paragraph">當您使用剛才設定的「標準型/進階型金鑰保險箱」來存放 AES 金鑰（祕密）時，費用主要由以下兩個部分組成：</p>



<h2 class="wp-block-heading">1. 交易費用（操作次數計費）—— 主要花費來源</h2>



<p class="wp-block-paragraph">只要您的 Python 或 Go 程式呼叫一次 SDK 去撈取、寫入金鑰，或是進行加解密，就散算一次「交易 (Operations)」。</p>



<ul class="wp-block-list">
<li><strong>祕密作業（讀取/寫入像您的 AES Key）：</strong>每 10,000 次存取，大約只要 <strong>$0.03 美元</strong>（折合台幣約 <strong>$1 元</strong>）。</li>



<li><strong>金鑰作業（如果您是用進階層的 RSA 金鑰，讓 HSM 幫您做運算）：</strong>標準金鑰每 10,000 次約 $0.03 美元；如果是進階層的 <strong>HSM 金鑰</strong>，每 10,000 次約 <strong>$1 美元</strong>（約台幣 $32 元）。</li>
</ul>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4a1.png" alt="💡" class="wp-smiley" style="height: 1em; max-height: 1em;" /> <strong>為什麼您的程式不會花大錢？（省錢關鍵技巧）</strong></p>



<p class="wp-block-paragraph">您的 Container 程式應該只有在**「啟動時」**去 Key Vault 撈取一次 AES 金鑰，並將它常駐在記憶體中。後續資料庫欄位加解密，都是在 Container 內部用記憶體裡的金鑰做運算，<strong>並不會每次解密都去跑一次 Key Vault 連線</strong>。</p>



<p class="wp-block-paragraph">因此，即使您的資料庫每天有幾百萬筆資料在加解密，您對 Key Vault 的存取次數可能一天也只有幾次（當容器重啟時），費用近乎為零。</p>
</blockquote>



<h2 class="wp-block-heading">2. 續存費用（放著不動的租金）</h2>



<p class="wp-block-paragraph">這是指您在 Key Vault 裡面建立金鑰後，每個月放在裡面的保管費。</p>



<ul class="wp-block-list">
<li><strong>祕密 (Secrets)：</strong> 儲存祕密（如您的 AES Base64 字串）是<strong>完全免費</strong>的！微軟不收儲存費，只收上面的存取次數費。</li>



<li><strong>金鑰 (Keys) —— 僅限進階層的 HSM 金鑰：</strong> 如果您在進階層裡建立了一把受硬體保護的 RSA 金鑰，每把金鑰每個月會收取 <strong>$1.00 美元</strong>（約台幣 $32 元）的保管費。</li>
</ul>



<h2 class="wp-block-heading">3. 網路流量費用 (頻寬費用)</h2>



<ul class="wp-block-list">
<li>如果您的 Container 部署在 <strong>Azure 同一個區域</strong>（例如您剛剛截圖中的 <code>japaneast</code> 日本東部），Container 存取同區域的 Key Vault，<strong>網路流量是完全免費的</strong>。</li>



<li>如果您的 Container 在地端機房，每天無休止地拼命讀取 Azure Key Vault，才會收取微乎其微的 Azure 資料外流（Egress）費用。</li>
</ul>



<h2 class="wp-block-heading"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4b0.png" alt="💰" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 總結：您大約會花多少錢？</h2>



<p class="wp-block-paragraph">假設您使用的是<strong>標準型金鑰保險箱</strong>，並將 AES 金鑰存放在「祕密」中：</p>



<ul class="wp-block-list">
<li><strong>金鑰租金：</strong> $0 元（祕密免租金）。</li>



<li><strong>存取費用：</strong> 假設您的容器因為自動擴展、更新，一個月重啟並存取了 Key Vault 1,000 次。</li>



<li><strong>每月總帳單：</strong> $0.003 美元（<strong>不到台幣 0.1 元</strong>）。</li>
</ul>



<p class="wp-block-paragraph">因此，把金鑰放進 Key Vault 換取極高的安全性與防遺失保護，是非常划算且必須的投資！</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">當您的 Container 運行在 <strong>Azure Container Apps (ACA)</strong> 之下時，您需要授權的對象就是「這台 ACA 應用程式本身」。</p>



<p class="wp-block-paragraph">在 Azure 中，這需要透過啟用 ACA 的 <strong>「受控識別 (Managed Identity)」</strong> 功能來達成。它會幫您的 ACA 在 Microsoft Entra ID (舊稱 Azure AD) 中註冊一個專屬的身分，接著您只要去 Key Vault 授權這個身分即可。</p>



<p class="wp-block-paragraph">以下是完整的設定與授權步驟（推薦使用最簡單且常見的<strong>系統指派受控識別</strong>）：</p>



<h3 class="wp-block-heading">第一步：在 ACA 啟用受控識別 (Managed Identity)</h3>



<ol start="1" class="wp-block-list">
<li>登入 Azure Portal，找到並進入您的 <strong>Container App (ACA)</strong> 資源頁面。</li>



<li>在左側選單中，找到「設定」分類下的 <strong>「身分識別 (Identity)」</strong>。</li>



<li>在「系統指派 (System assigned)」標籤頁中，將「狀態 (Status)」切換為 <strong>「開啟 (On)」</strong>。</li>



<li>點擊頁面上的 <strong>「儲存 (Save)」</strong>。</li>



<li>系統會跳出確認視窗，點擊「是」。儲存成功後，畫面上會出現一串專屬於這台 ACA 的 <strong>「主體識別碼 (Object ID / Principal ID)」</strong>。</li>
</ol>



<h3 class="wp-block-heading">第二步：到 Key Vault 對該 ACA 進行授權</h3>



<p class="wp-block-paragraph">由於您先前的 <code>kv-pr-stg-jpe-001</code> 是使用 <strong>Azure RBAC</strong> 權限模型（您先前截圖中藍色勾選的推薦項目），請依照以下步驟授權：</p>



<ol start="1" class="wp-block-list">
<li>前往您的金鑰保險箱 <strong><code>kv-pr-stg-jpe-001</code></strong> 資源頁面。</li>



<li>點擊左側選單的 <strong>「存取控制 (IAM)」</strong>。</li>



<li>點擊上方的 <strong>「+ 新增 (Add)」</strong> ➔ 選擇 <strong>「新增角色指派 (Add role assignment)」</strong>。</li>



<li><strong>選擇角色：</strong> * 搜尋並選擇 <strong><code>Key Vault Secrets User</code>（金鑰保險箱祕密使用者）</strong>。
<ul class="wp-block-list">
<li><em>(說明：因為您的 ACA 容器只需要在啟動時「讀取 (Get)」金鑰，不需要像您之前測試時需要寫入，所以給予 <code>Secrets User</code> 的唯讀權限是最安全的「最小權限原則」)</em>。</li>



<li>選好後點擊 <strong>「下一步」</strong>。</li>
</ul>
</li>



<li><strong>選取成員：</strong>
<ul class="wp-block-list">
<li>「指派存取權給」保持選擇 <strong>「使用者、群組或服務主體」</strong>。</li>



<li>點擊 <strong>「+ 選取成員 (+ Select members)」</strong>。</li>



<li>在右側跳出的搜尋欄中，<strong>直接輸入您這台 Container App (ACA) 的名稱</strong>（或是輸入剛剛在 ACA 看到的 Object ID）。</li>



<li>在搜尋結果中點選您的 ACA，它會顯示在下方列表中，然後點擊「選取」。</li>
</ul>
</li>



<li><strong>檢閱並指派：</strong> 一路點擊「下一步」，最後點擊 <strong>「檢閱並指派 (Review + assign)」</strong>。</li>
</ol>



<h3 class="wp-block-heading"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4a1.png" alt="💡" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 程式碼如何自動通訊？</h3>



<p class="wp-block-paragraph">當您完成上述兩步後，您 Python 程式碼中的這一行：</p>



<p class="wp-block-paragraph">Python</p>



<pre class="wp-block-code"><code>credential = DefaultAzureCredential()
</code></pre>



<p class="wp-block-paragraph">在 ACA 容器環境內運行到這行時，Azure SDK 會自動執行以下神奇的流程：</p>



<ol start="1" class="wp-block-list">
<li>它會發現自己不是在個人電腦上（沒有 Azure CLI 登入憑證），而是身處於 Azure 雲端環境中。</li>



<li>它會自動向 ACA 的內部端點請求剛才啟用的「系統指派受控識別」權杖（Token）。</li>



<li>拿著這個 Token 去敲 <code>kv-pr-stg-jpe-001</code> 的門。</li>



<li>Key Vault 檢查 IAM 紀錄，發現這台 ACA 擁有 <code>Key Vault Secrets User</code> 權限，於是便放行，將 AES 金鑰安全地回傳給您的 Python 程式。</li>
</ol>



<p class="wp-block-paragraph">整個過程<strong>完全不需要輸入任何帳號密碼，也不需要設定任何 <code>.env</code> 變數</strong>，是目前雲端部署最安全、最高級的做法！</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">要更新您儲存在 Azure Key Vault 中的金鑰（祕密）數值，有兩種最常見的方法：<strong>直接在 Azure Portal 網頁上手動更新</strong>，或是<strong>使用 Python 腳本自動更新</strong>。</p>



<p class="wp-block-paragraph">由於 Key Vault 具有「版本控制」功能，當您更新數值時，它不會覆蓋舊的資料，而是會<strong>產生一個全新的版本 (Current Version)</strong>，這能確保如果新金鑰有問題，隨時可以復原。</p>



<p class="wp-block-paragraph">以下為您說明這兩種更新方式：</p>



<h3 class="wp-block-heading">方法一：透過 Azure Portal 網頁手動更新 (最簡單)</h3>



<ol start="1" class="wp-block-list">
<li>登入 Azure Portal，進入您的金鑰保險箱 <strong><code>kv-pr-stg-jpe-001</code></strong>。</li>



<li>在左側選單中，點選「物件」分類下的 <strong>「祕密 (Secrets)」</strong>。</li>



<li>在列表中點選您之前建立的祕密名稱：<strong><code>projectname-database-column-aes-key</code></strong>。</li>



<li>進入後，您會看到目前現有的版本列表。請點擊上方的 <strong>「+ 新增版本 (New Version)」</strong> 按鈕。</li>



<li>在 <strong>「祕密值 (Secret value)」</strong> 欄位中，<strong>貼上您新產生的 AES Base64 字串</strong>。</li>



<li>其他設定（如啟用日期、到期日）保持預設，直接點擊底部的 <strong>「建立 (Create)」</strong>。</li>
</ol>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4a1.png" alt="💡" class="wp-smiley" style="height: 1em; max-height: 1em;" /> <strong>手動更新完就結束了嗎？</strong></p>



<p class="wp-block-paragraph">沒錯！因為您的 Python / Go 程式碼在呼叫 <code>client.get_secret("secret_name")</code> 時，如果沒有指定特定的版本識別碼，Azure SDK <strong>預設永遠會自動下載最新生效的版本 (Latest/Current Version)</strong>。當您的 ACA 容器下次重啟時，就會自動吃進這把新金鑰。</p>
</blockquote>



<h3 class="wp-block-heading">方法二：使用 Python 腳本自動更新</h3>



<p class="wp-block-paragraph">如果您希望定期透過自動化程式或排程來更新 Key Vault 裡的數值，可以使用當初幫您設定的 <code>SecretClient</code>。在 Python 中，不論是「第一次建立」還是「後續更新」，使用的都是同一個方法：<strong><code>set_secret</code></strong>。</p>



<h4 class="wp-block-heading"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f40d.png" alt="🐍" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Python 更新腳本範例：</h4>



<p class="wp-block-paragraph">Python</p>



<pre class="wp-block-code"><code>import base64
import os
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient

def generate_new_aes_key():
    """產生一組全新的隨機 AES-256 金鑰 Base64 字串"""
    raw_key = os.urandom(32)
    return base64.b64encode(raw_key).decode('utf-8')

def update_key_vault_secret(new_value):
    vault_url = "https://kv-pr-stg-jpe-001.vault.azure.net/"
    secret_name = "projectname-database-column-aes-key"
    
    # 這裡會使用您電腦上的 Azure CLI 登入身分
    # 請確保您的帳號目前在該 Key Vault 的 IAM 擁有「Key Vault Secrets Officer」角色
    credential = DefaultAzureCredential()
    client = SecretClient(vault_url=vault_url, credential=credential)
    
    print(f"正在將新金鑰更新至 Key Vault 中的 '{secret_name}'...")
    
    # set_secret 如果遇到同名的祕密，會自動為其建立一個「最新版本」
    updated_secret = client.set_secret(secret_name, new_value)
    
    print("【成功】金鑰已成功更新！")
    print(f"新版本識別碼 (Version ID): {updated_secret.properties.version}")

if __name__ == "__main__":
    # 1. 產生新的金鑰值
    new_aes_key = generate_new_aes_key()
    
    # 2. 推送到 Azure Key Vault
    update_key_vault_secret(new_aes_key)
</code></pre>



<h3 class="wp-block-heading"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 更新金鑰（輪替 Key）的資安與系統注意事項</h3>



<p class="wp-block-paragraph">當您更新了 <code>projectname-database-column-aes-key</code> 之後，系統中會存在一個<strong>新舊金鑰交替的過渡期</strong>，請務必注意以下邏輯：</p>



<ol start="1" class="wp-block-list">
<li><strong>舊資料的解密問題：</strong>之前已經用「舊金鑰」加密並存進資料庫的資料，用「新金鑰」是<strong>絕對解不開</strong>的。</li>



<li><strong>正確的輪替 (Rotation) 策略：</strong>
<ul class="wp-block-list">
<li><strong>新資料：</strong> 程式重啟、讀取新金鑰後，所有「新寫入」的資料庫欄位都會用新金鑰加密。</li>



<li><strong>舊資料讀取：</strong> 如果您有定期更換金鑰的需求，通常會建議在資料庫的密文欄位旁，多開一個欄位紀錄「金鑰版本」。或者，在更新 Key Vault 的當下，寫一支遷移程式（Migration Script）把資料庫現有的密文全部用舊金鑰解開、再用新金鑰重新加密塞回去。</li>
</ul>
</li>
</ol>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/05/azure-key-vault/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Azure Portal 設定 User.ReadWrite.All 應用程式權限</title>
		<link>https://stackoverflow.max-everyday.com/2026/05/azure-portal-user-readwrite-all/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/05/azure-portal-user-readwrite-all/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Wed, 27 May 2026 05:25:21 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[azure]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8474</guid>

					<description><![CDATA[沒問題！以下是為您整理的 Microsoft A...]]></description>
										<content:encoded><![CDATA[
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="589" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/05/chrome_2026-05-27-13-24-8r-1024x589.jpg?v=1779859492" alt="" class="wp-image-8476" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/05/chrome_2026-05-27-13-24-8r-1024x589.jpg?v=1779859492 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/05/chrome_2026-05-27-13-24-8r-600x345.jpg?v=1779859492 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/05/chrome_2026-05-27-13-24-8r-768x442.jpg?v=1779859492 768w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/05/chrome_2026-05-27-13-24-8r-1536x883.jpg?v=1779859492 1536w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/05/chrome_2026-05-27-13-24-8r.jpg?v=1779859492 1736w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">沒問題！以下是為您整理的 Microsoft Azure 門戶（Azure Portal）設定 <code>User.ReadWrite.All</code> 應用程式權限的詳細步驟指南：</p>



<h3 class="wp-block-heading">步驟 1：進入 App Registrations (應用程式註冊)</h3>



<ol start="1" class="wp-block-list">
<li>登入 <a href="https://www.google.com/search?q=https://portal.azure.com/" target="_blank" rel="noreferrer noopener">Azure Portal</a>。</li>



<li>在頂部搜尋列輸入 <strong>「App registrations」</strong>（應用程式註冊）並點選進入。</li>



<li>在清單中，點選您目前正在開發、需要調整權限的那一個<strong>應用程式名稱</strong>。</li>
</ol>



<h3 class="wp-block-heading">步驟 2：新增 API 權限</h3>



<ol start="1" class="wp-block-list">
<li>進入應用程式頁面後，點選左側選單的 <strong>API permissions</strong>（API 權限）。</li>



<li>在右側主畫面中，點選 <strong>+ Add a permission</strong>（+ 新增權限）。</li>
</ol>



<h3 class="wp-block-heading">步驟 3：選擇 Microsoft Graph 與權限類型</h3>



<ol start="1" class="wp-block-list">
<li>在彈出的「Request API permissions」右側視窗中，點選最上方的 <strong>Microsoft Graph</strong>。</li>



<li>接下來系統會詢問您的應用程式需要哪種權限類型，請點選 <strong>Application permissions</strong>（應用程式權限）。<img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4a1.png" alt="💡" class="wp-smiley" style="height: 1em; max-height: 1em;" /> <strong>為什麼選這個？</strong> 因為密碼寫入與背景自動化服務需要 Application 類型（也就是以應用程式自身身分執行），原本的 <code>User.Read.All</code> 或委派權限（Delegated）是不夠的。</li>
</ol>



<h3 class="wp-block-heading">步驟 4：搜尋並勾選 <code>User.ReadWrite.All</code></h3>



<ol start="1" class="wp-block-list">
<li>在下方的搜尋欄位中輸入：<code>User.ReadWrite.All</code>。</li>



<li>展開 <strong>User</strong> 群組，<strong>勾選</strong> <code>User.ReadWrite.All</code> 前方的正方形欄位。</li>



<li>勾選完畢後，點選最下方的 <strong>Add permissions</strong>（新增權限）按鈕。</li>
</ol>



<h3 class="wp-block-heading">步驟 5：授予管理員同意 (Grant Admin Consent)</h3>



<p class="wp-block-paragraph">這是最關鍵的一步，沒有點擊的話權限將不會生效：</p>



<ol start="1" class="wp-block-list">
<li>回到 API permissions 主頁面後，您會看到新加入的 <code>User.ReadWrite.All</code> 狀態顯示為三角形的「Not granted&#8230;」（尚未同意）。</li>



<li>點選權限清單上方的 <strong>Grant admin consent for &lt;您的租戶名稱></strong>（授予管理員同意）。</li>



<li>系統彈出確認視窗時，點選 <strong>Yes</strong>（是）。</li>
</ol>



<h3 class="wp-block-heading">檢查最終結果</h3>



<p class="wp-block-paragraph">成功後，該權限的 <strong>Status</strong>（狀態）會變成綠色的打勾記號：<strong>Granted for &lt;您的租戶名稱&gt;</strong>。這樣一來，您的應用程式就擁有透過 <code>PATCH /users/{id}</code> 來修改使用者密碼的權限了！</p>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/05/azure-portal-user-readwrite-all/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>不用密碼連 Azure SQL：用 Token 驗證搭配 fast_executemany 解決效能瓶頸</title>
		<link>https://stackoverflow.max-everyday.com/2026/05/azure-sql-token-fast_executemany/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/05/azure-sql-token-fast_executemany/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Mon, 25 May 2026 06:05:26 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[azure]]></category>
		<category><![CDATA[mssql]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8468</guid>

					<description><![CDATA[前言 在將本地資料同步到 Azure SQL D...]]></description>
										<content:encoded><![CDATA[
<h2 class="wp-block-heading">前言</h2>



<p class="wp-block-paragraph">在將本地資料同步到 Azure SQL Database 的專案中，我遇到了兩個問題：</p>



<ol class="wp-block-list">
<li><strong>安全性</strong>：連線字串裡不想寫死帳號密碼</li>



<li><strong>效能</strong>：用 <code>pyodbc</code> 逐筆 UPDATE 速度慢到不行</li>
</ol>



<p class="wp-block-paragraph">這篇文章紀錄我怎麼用 <strong>Azure Managed Identity + Access Token</strong> 取代密碼驗證，並透過 <code>fast_executemany</code> 大幅提升批次寫入效能。同時也整理了幾個常見疑問：有沒有 Token 差在哪、<code>ActiveDirectoryMsi</code> 與手動 Token 有何不同、兩者效能是否有差。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">環境</h2>



<ul class="wp-block-list">
<li>Python 3.11+</li>



<li><code>pyodbc</code> + ODBC Driver 18 for SQL Server</li>



<li><code>azure-identity</code></li>



<li>Azure SQL Database（已啟用 Entra ID / Managed Identity）</li>
</ul>



<pre class="wp-block-code"><code>pip install pyodbc azure-identity</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">問題一：不想在程式裡放密碼</h2>



<h3 class="wp-block-heading">有無 Access Token 的差別</h3>



<p class="wp-block-paragraph">傳統帳密驗證的連線字串長這樣：</p>



<pre class="wp-block-code"><code># 傳統帳密驗證
conn_str = (
    "Driver={ODBC Driver 18 for SQL Server};"
    "Server=your-server.database.windows.net;"
    "Database=your-db;"
    "UID=myuser;"
    "PWD=mypassword;"
)
conn = pyodbc.connect(conn_str)</code></pre>



<p class="wp-block-paragraph">Access Token 驗證則是拿掉帳密，改用 <code>attrs_before</code> 注入 Token：</p>



<pre class="wp-block-code"><code># Access Token 驗證
conn_str = (
    "Driver={ODBC Driver 18 for SQL Server};"
    "Server=your-server.database.windows.net;"
    "Database=your-db;"
    "Encrypt=yes;"
    # 沒有 UID / PWD
)
conn = pyodbc.connect(conn_str, attrs_before={1256: token_struct})</code></pre>



<p class="wp-block-paragraph">兩者的核心差異：</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th></th><th>帳密</th><th>Access Token</th></tr></thead><tbody><tr><td>驗證來源</td><td>SQL Server 本地帳號</td><td>Azure Entra ID (AAD)</td></tr><tr><td>密碼管理</td><td>要自己管、要輪換</td><td>由 Azure 管，短效 Token 自動刷新</td></tr><tr><td>程式碼安全</td><td>密碼容易外洩</td><td>無密碼，較安全</td></tr><tr><td>CI/CD / Managed Identity</td><td>需要把密碼塞進環境變數</td><td>直接用 Managed Identity，不需任何密碼</td></tr><tr><td>本機開發</td><td>帳密寫死或讀 <code>.env</code></td><td><code>az login</code> 後自動生效</td></tr></tbody></table></figure>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>一句話總結</strong>：帳密是「你知道什麼」，Token 是「你是誰（身份）」，在雲端環境推薦用 Token，省去密碼管理的麻煩。</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">解法：用 Azure Identity 取得 Access Token</h3>



<p class="wp-block-paragraph"><code>azure-identity</code> 套件提供的 <code>DefaultAzureCredential</code> 可以依序嘗試多種驗證方式（環境變數、Managed Identity、Azure CLI 登入等），在本機開發和正式環境都能無縫切換。</p>



<pre class="wp-block-code"><code>import struct
import pyodbc
from azure.identity import DefaultAzureCredential

# 在程式啟動時初始化一次，讓 Token 可以被快取重複使用
azure_credential = DefaultAzureCredential()

def get_mssql_connection(server, database, driver):
    # 每次建立連線時呼叫 get_token()，有快取就回快取，快到期會自動刷新
    token_object = azure_credential.get_token("https://database.windows.net/.default")

    # pyodbc 要求 Token 必須以特定格式打包
    token_bytes = token_object.token.encode("utf-16-le")
    token_struct = struct.pack(f"&lt;I{len(token_bytes)}s", len(token_bytes), token_bytes)

    # 連線字串不帶 UID/PWD，改用 attrs_before 注入 Token
    # SQL_COPT_SS_ACCESS_TOKEN = 1256
    conn_str = f"Driver={driver};Server={server};Database={database};Encrypt=yes;TrustServerCertificate=no;"
    conn = pyodbc.connect(conn_str, attrs_before={1256: token_struct})
    return conn</code></pre>



<p class="wp-block-paragraph"><strong>關鍵細節：</strong></p>



<ul class="wp-block-list">
<li>Token 必須先用 <code>utf-16-le</code> 編碼，再用 <code>struct.pack</code> 包成 <code>&lt;I{n}s</code> 格式</li>



<li><code>attrs_before={1256: token_struct}</code> 是 pyodbc 注入 <code>SQL_COPT_SS_ACCESS_TOKEN</code> 的方式</li>



<li>連線字串裡<strong>不能</strong>加 <code>Authentication=...</code>，否則會與 Token 驗證衝突</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">補充：<code>Authentication=ActiveDirectoryMsi</code> vs 手動 Access Token</h2>



<p class="wp-block-paragraph">Azure SQL 還有另一種無密碼寫法：在連線字串直接加 <code>Authentication=ActiveDirectoryMsi</code>，讓 ODBC Driver 自己去拿 Token：</p>



<pre class="wp-block-code"><code># ActiveDirectoryMsi 寫法：Driver 自己處理 Token
conn_str = (
    "Driver={ODBC Driver 18 for SQL Server};"
    "Server=your-server.database.windows.net;"
    "Database=your-db;"
    "Authentication=ActiveDirectoryMsi;"
    "Encrypt=yes;"
)
conn = pyodbc.connect(conn_str)</code></pre>



<p class="wp-block-paragraph">兩種方式的比較：</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th></th><th><code>ActiveDirectoryMsi</code></th><th>手動 Access Token</th></tr></thead><tbody><tr><td>程式碼複雜度</td><td>簡單，Driver 全包</td><td>需要自己打包 <code>struct</code></td></tr><tr><td>套件依賴</td><td>只需 <code>pyodbc</code></td><td>需要 <code>azure-identity</code></td></tr><tr><td>Token 快取</td><td>Driver 自己管，不透明</td><td>自己控制（全域 credential 物件）</td></tr><tr><td>彈性</td><td>僅限 MSI</td><td>可用任何 Credential（CLI、SP、環境變數…）</td></tr><tr><td>本機開發</td><td><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/274c.png" alt="❌" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 只能在 Azure 環境跑</td><td><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2705.png" alt="✅" class="wp-smiley" style="height: 1em; max-height: 1em;" /> <code>az login</code> 本機也能用</td></tr><tr><td>User-Assigned MSI</td><td>需加 <code>UID=client-id</code></td><td><code>ManagedIdentityCredential(client_id=...)</code></td></tr></tbody></table></figure>



<h3 class="wp-block-heading">效能有差嗎？</h3>



<p class="wp-block-paragraph"><strong>連線建立後的 SQL 執行效能：完全一樣。</strong> 差別只在取 Token 的開銷，且僅影響建立連線那一刻：</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th></th><th><code>ActiveDirectoryMsi</code></th><th>手動 <code>DefaultAzureCredential</code></th></tr></thead><tbody><tr><td>誰去打 IMDS</td><td>ODBC Driver（每次 <code>connect()</code> 都可能觸發）</td><td><code>azure-identity</code>（有記憶體快取）</td></tr><tr><td>Token 快取</td><td>Driver 內部管理，<strong>不透明</strong>，無法控制</td><td>全域 credential 物件自動快取，<strong>明確可控</strong></td></tr><tr><td>快取失效前重連</td><td>可能再打一次 IMDS</td><td>直接回傳快取 Token，<strong>不打網路</strong></td></tr></tbody></table></figure>



<h3 class="wp-block-heading">該選哪個？</h3>



<p class="wp-block-paragraph">實務上幾乎不用思考，<strong>直接選手動管理</strong>就對了——只要記住一個原則：</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>每次 <code>connect()</code> 都重新呼叫 <code>get_token()</code>，不要把打包好的 <code>token_struct</code> 存起來重複用。</strong></p>
</blockquote>



<pre class="wp-block-code"><code># &#x274c; 危險：token_struct 是靜態 bytes，1 小時後 Token 過期，重連時會爆
token_struct = pack_token(azure_credential.get_token(...))
# ... 之後某處重新 connect() 但沿用舊的 token_struct

# &#x2705; 正確：每次建立連線時才呼叫 get_token()
def get_conn():
    token = azure_credential.get_token(...)  # 有快取就回快取，快到期自動刷新
    return pyodbc.connect(conn_str, attrs_before={1256: pack_token(token)})</code></pre>



<p class="wp-block-paragraph"><code>azure-identity</code> 的 <code>get_token()</code> 本身有快取，不用擔心每次都打網路，讓它去判斷就好。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">問題二：逐筆 UPDATE 太慢</h2>



<p class="wp-block-paragraph">初版程式是這樣跑的：</p>



<pre class="wp-block-code"><code>for row in rows:
    cursor.execute("UPDATE users SET name = ?, email = ? WHERE account = ?", row)
conn.commit()</code></pre>



<p class="wp-block-paragraph">幾百筆資料就要花好幾秒，每一筆都是一次來回的 network round-trip。</p>



<h3 class="wp-block-heading">解法：<code>executemany</code> + <code>fast_executemany = True</code></h3>



<pre class="wp-block-code"><code>cursor = conn.cursor()

# 啟用 fast_executemany：改用 ODBC 的批次參數傳輸，大幅減少 round-trip
cursor.fast_executemany = True

sql = "UPDATE users SET name = ?, email = ? WHERE account = ?"
cursor.executemany(sql, rows)  # rows 是 list of tuples

conn.commit()</code></pre>



<p class="wp-block-paragraph"><code>fast_executemany</code> 是 pyodbc 的一個優化開關，啟用後會將所有參數打包成一個批次送出，而不是一筆一筆傳送，對幾百到幾千筆的批次操作效果非常明顯。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">完整範例</h2>



<pre class="wp-block-code"><code>import sqlite3
import pyodbc
import os
import struct
from azure.identity import DefaultAzureCredential

SERVER   = "your-server.database.windows.net"
DATABASE = "your-database"
DRIVER   = "{ODBC Driver 18 for SQL Server}"

# 全域初始化一次，Token 快取由 azure-identity 管理
azure_credential = DefaultAzureCredential()


def get_mssql_connection():
    # 每次連線都呼叫 get_token()，讓 azure-identity 決定要不要刷新
    token_object = azure_credential.get_token("https://database.windows.net/.default")
    token_bytes  = token_object.token.encode("utf-16-le")
    token_struct = struct.pack(f"&lt;I{len(token_bytes)}s", len(token_bytes), token_bytes)
    conn_str = (
        f"Driver={DRIVER};Server={SERVER};Database={DATABASE};"
        "Encrypt=yes;TrustServerCertificate=no;"
    )
    return pyodbc.connect(conn_str, attrs_before={1256: token_struct})


def sync_sqlite_to_mssql(sqlite_path: str):
    if not os.path.exists(sqlite_path):
        print(f"SQLite not found: {sqlite_path}")
        return

    with sqlite3.connect(sqlite_path) as sqlite_conn:
        rows = sqlite_conn.execute(
            "SELECT name, email, account FROM employee WHERE status = 'active'"
        ).fetchall()

    if not rows:
        print("No rows to sync.")
        return

    print(f"Syncing {len(rows)} rows...")

    with get_mssql_connection() as mssql_conn:
        cursor = mssql_conn.cursor()
        cursor.fast_executemany = True
        cursor.executemany(
            "UPDATE users SET name = ?, email = ? WHERE account = ?",
            rows,
        )
        mssql_conn.commit()

    print("Done.")


if __name__ == "__main__":
    sync_sqlite_to_mssql("portal.db")</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">前置設定：讓 Managed Identity 能存取 Azure SQL</h2>



<p class="wp-block-paragraph">在 Azure SQL 裡執行以下 T-SQL，把你的 Managed Identity 或 Entra ID 帳號加入：</p>



<pre class="wp-block-code"><code>-- 使用 Azure AD 帳號登入後執行
CREATE USER &#91;your-managed-identity-name] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER &#91;your-managed-identity-name];
ALTER ROLE db_datawriter ADD MEMBER &#91;your-managed-identity-name];</code></pre>



<p class="wp-block-paragraph">本機開發時，用 Azure CLI 登入後 <code>DefaultAzureCredential</code> 就會自動使用你的帳號：</p>



<pre class="wp-block-code"><code>az login</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">小結</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>問題</th><th>解法</th></tr></thead><tbody><tr><td>連線字串含密碼</td><td><code>DefaultAzureCredential</code> + Access Token (<code>SQL_COPT_SS_ACCESS_TOKEN</code>)</td></tr><tr><td>逐筆 UPDATE 慢</td><td><code>cursor.fast_executemany = True</code> + <code>executemany()</code></td></tr><tr><td>本機 / 正式環境切換麻煩</td><td><code>DefaultAzureCredential</code> 自動偵測驗證來源</td></tr><tr><td><code>ActiveDirectoryMsi</code> vs 手動 Token</td><td>手動管理更靈活，本機開發也能用，直接選它</td></tr><tr><td>Token 過期問題</td><td>每次 <code>connect()</code> 都呼叫 <code>get_token()</code>，不要快取 <code>token_struct</code></td></tr></tbody></table></figure>



<p class="wp-block-paragraph">兩個改動加起來不超過 10 行，卻同時解決了安全性與效能問題，值得加進你的 Azure SQL 工具箱。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/05/azure-sql-token-fast_executemany/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>實作教學：使用 Azure Blob Storage 上傳附件檔案</title>
		<link>https://stackoverflow.max-everyday.com/2026/05/azure-blob-storage-attachment/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/05/azure-blob-storage-attachment/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Mon, 25 May 2026 05:48:15 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[azure]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8466</guid>

					<description><![CDATA[讀者對象：具備基本 Go 後端與 React 前...]]></description>
										<content:encoded><![CDATA[
<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>讀者對象</strong>：具備基本 Go 後端與 React 前端開發經驗的 Web 工程師。<br><strong>技術棧</strong>：Go + sqlx (MSSQL) + React + TypeScript + Mantine UI + Azure Blob Storage<br><strong>完成目標</strong>：在後端 API 接收檔案上傳、儲存至 Azure Blob Storage，並在前端提供拖放介面讓使用者上傳、列出、刪除附件，最後在公開頁面顯示可下載連結。</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">目錄</h2>



<ol class="wp-block-list">
<li><a href="#架構概覽">架構概覽</a></li>



<li><a href="#azure-前置設定">Azure 前置設定</a></li>



<li><a href="#後端實作">後端實作</a></li>
</ol>



<ul class="wp-block-list">
<li><a href="#資料庫-migration">資料庫 Migration</a></li>



<li><a href="#model-定義">Model 定義</a></li>



<li><a href="#blob-storage-客戶端">Blob Storage 客戶端</a></li>



<li><a href="#資料庫查詢層-queries">資料庫查詢層 (Queries)</a></li>



<li><a href="#service-層">Service 層</a></li>



<li><a href="#http-handler">HTTP Handler</a></li>



<li><a href="#路由註冊">路由註冊</a></li>



<li><a href="#設定管理">設定管理</a></li>
</ul>



<ol class="wp-block-list">
<li><a href="#前端實作">前端實作</a></li>
</ol>



<ul class="wp-block-list">
<li><a href="#型別定義">型別定義</a></li>



<li><a href="#api-呼叫層">API 呼叫層</a></li>



<li><a href="#管理介面元件上傳--刪除">管理介面元件（上傳 / 刪除）</a></li>



<li><a href="#公開展示元件下載連結">公開展示元件（下載連結）</a></li>
</ul>



<ol class="wp-block-list">
<li><a href="#效能考量n1-查詢問題">效能考量：N+1 查詢問題</a></li>



<li><a href="#部署至-azure-container-apps">部署至 Azure Container Apps</a></li>



<li><a href="#常見問題與除錯">常見問題與除錯</a></li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">架構概覽</h2>



<pre class="wp-block-code"><code>使用者瀏覽器
   │
   ├─ 管理介面 (React)
   │      │  POST multipart/form-data
   │      ▼
   │   後端 API (Go)
   │      │  azure-sdk-for-go
   │      ▼
   │   Azure Blob Storage ← 實際儲存二進位檔案
   │      │  公開 blob_url
   │      ▼
   └─ 公開頁面 (React)  直接使用 blob_url 提供下載連結
          │
          ▼
       資料庫 (MSSQL)  只儲存中繼資料 (file_name, blob_url, content_type, file_size)</code></pre>



<p class="wp-block-paragraph"><strong>設計原則：</strong></p>



<ul class="wp-block-list">
<li>後端負責「鑑權 → 上傳 Blob → 寫入 DB 中繼資料」這三步的原子化處理</li>



<li>資料庫只存 URL 和中繼資料，不存二進位內容</li>



<li>Blob 容器設定為公開讀取（<code>Blob</code> 層級），讓前端可直接用 URL 下載，不需要後端代理</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Azure 前置設定</h2>



<h3 class="wp-block-heading">1. 建立 Storage Account</h3>



<p class="wp-block-paragraph">在 Azure Portal 或 Azure CLI 建立儲存體帳戶：</p>



<pre class="wp-block-code"><code>az storage account create \
  --name mystorageaccount \
  --resource-group my-resource-group \
  --location japaneast \
  --sku Standard_LRS \
  --kind StorageV2</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>注意</strong>：Azure 新版儲存體帳戶預設<strong>停用匿名公開存取</strong>。<br>若需要公開 URL 直接下載，必須在帳戶層級啟用後，再於容器層級設定：</p>
</blockquote>



<pre class="wp-block-code"><code># 啟用帳戶層級的 Blob 公開存取
az storage account update \
  --name mystorageaccount \
  --resource-group my-resource-group \
  --allow-blob-public-access true</code></pre>



<h3 class="wp-block-heading">2. 取得 Connection String</h3>



<pre class="wp-block-code"><code>az storage account show-connection-string \
  --name mystorageaccount \
  --resource-group my-resource-group \
  --output tsv</code></pre>



<p class="wp-block-paragraph">輸出範例：</p>



<pre class="wp-block-code"><code>DefaultEndpointsProtocol=https;AccountName=mystorageaccount;AccountKey=&lt;base64-key&gt;;...</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> <strong>安全提醒</strong>：Connection String 包含完整存取金鑰，切勿提交進 Git。<br>在 Azure Container Apps / App Service 中應存成 <strong>Secret</strong>，再以環境變數參照。</p>
</blockquote>



<h3 class="wp-block-heading">3. 建立容器並設定公開存取</h3>



<pre class="wp-block-code"><code>az storage container create \
  --name my-container \
  --account-name mystorageaccount \
  --account-key "&lt;your-account-key&gt;" \
  --public-access blob \
  --auth-mode key</code></pre>



<p class="wp-block-paragraph"><code>--public-access blob</code> 表示：</p>



<ul class="wp-block-list">
<li>容器本身不可列出（私有）</li>



<li>容器內每個 blob 的 URL 可直接讀取（公開）</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">後端實作</h2>



<h3 class="wp-block-heading">資料庫 Migration</h3>



<p class="wp-block-paragraph">建立一張中繼資料表，只存 blob URL 和檔案資訊，不存二進位內容：</p>



<pre class="wp-block-code"><code>-- migrations/0017_attachments.up.sql

IF NOT EXISTS (
    SELECT 1 FROM sys.objects
    WHERE object_id = OBJECT_ID('dbo.item_attachments') AND type = 'U'
)
BEGIN
    CREATE TABLE item_attachments (
        id          UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() PRIMARY KEY,
        item_id     UNIQUEIDENTIFIER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
        file_name   NVARCHAR(255)    NOT NULL,
        blob_url    NVARCHAR(2000)   NOT NULL,
        content_type NVARCHAR(100)   NOT NULL DEFAULT '',
        file_size   BIGINT           NOT NULL DEFAULT 0,
        created_at  DATETIME2        NOT NULL DEFAULT SYSUTCDATETIME()
    );

    CREATE INDEX idx_item_attachments_item_id ON item_attachments(item_id);
END;</code></pre>



<pre class="wp-block-code"><code>-- migrations/0017_attachments.down.sql
DROP TABLE IF EXISTS item_attachments;</code></pre>



<p class="wp-block-paragraph"><strong>重點說明：</strong></p>



<ul class="wp-block-list">
<li><code>ON DELETE CASCADE</code>：刪除父項目時自動刪除所有附件的 DB 記錄（但 Blob Storage 的實體檔案需另行刪除）</li>



<li><code>blob_url</code> 儲存完整 URL，前端可直接用作 <code>&lt;a href></code> 或 <code>window.open</code></li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Model 定義</h3>



<pre class="wp-block-code"><code>// internal/item/models.go
package item

import (
    "time"
    "github.com/google/uuid"
)

// Item 是業務主體（可以是公告、文章、報告等）
type Item struct {
    ID          uuid.UUID    `db:"id"          json:"id"`
    Title       string       `db:"title"       json:"title"`
    CreatedAt   time.Time    `db:"created_at"  json:"created_at"`
    UpdatedAt   time.Time    `db:"updated_at"  json:"updated_at"`
    Attachments &#91;]Attachment `db:"-"           json:"attachments"` // db:"-" 表示不直接映射欄位，由程式碼填入
}

// Attachment 儲存附件的中繼資料
type Attachment struct {
    ID          uuid.UUID `db:"id"           json:"id"`
    ItemID      uuid.UUID `db:"item_id"      json:"item_id"`
    FileName    string    `db:"file_name"    json:"file_name"`
    BlobURL     string    `db:"blob_url"     json:"blob_url"`
    ContentType string    `db:"content_type" json:"content_type"`
    FileSize    int64     `db:"file_size"    json:"file_size"`
    CreatedAt   time.Time `db:"created_at"   json:"created_at"`
}</code></pre>



<p class="wp-block-paragraph"><strong><code>db:"-"</code> 的意義：</strong><br><code>Attachments</code> 欄位不對應資料庫欄位（因為是一對多關聯，不在同一張表）。<br>我們在查詢後手動填入，這樣 JSON 序列化時仍會輸出 <code>"attachments": [...]</code>。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Blob Storage 客戶端</h3>



<pre class="wp-block-code"><code>// internal/storage/blob.go
package storage

import (
    "context"
    "fmt"
    "io"
    "net/url"
    "strings"

    "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob"
    "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob/blob"
)

type BlobClient struct {
    client    *azblob.Client
    container string
}

// NewBlobClient 建立 Azure Blob Storage 客戶端。
// connectionString 為空時回傳 nil（功能停用，不影響其他服務啟動）。
func NewBlobClient(connectionString, containerName string) (*BlobClient, error) {
    if connectionString == "" {
        return nil, nil // 附件功能未設定時，靜默略過
    }
    if containerName == "" {
        return nil, fmt.Errorf("azure storage container name is required")
    }

    client, err := azblob.NewClientFromConnectionString(connectionString, nil)
    if err != nil {
        return nil, fmt.Errorf("create blob client: %w", err)
    }

    // 嘗試建立容器；若已存在則忽略錯誤
    if _, err := client.CreateContainer(context.Background(), containerName, nil); err != nil {
        if !strings.Contains(err.Error(), "ContainerAlreadyExists") {
            return nil, fmt.Errorf("create blob container: %w", err)
        }
    }

    return &amp;BlobClient{client: client, container: containerName}, nil
}

// Upload 上傳資料流到指定的 blob 名稱，回傳公開 URL。
func (b *BlobClient) Upload(ctx context.Context, blobName string, data io.Reader, contentType string) (string, error) {
    if contentType == "" {
        contentType = "application/octet-stream"
    }

    _, err := b.client.UploadStream(ctx, b.container, blobName, data, &amp;azblob.UploadStreamOptions{
        HTTPHeaders: &amp;blob.HTTPHeaders{BlobContentType: &amp;contentType},
    })
    if err != nil {
        return "", fmt.Errorf("upload blob %s: %w", blobName, err)
    }

    // 組合公開 URL：https://&lt;account&gt;.blob.core.windows.net/&lt;container&gt;/&lt;blobName&gt;
    blobURL := fmt.Sprintf(
        "%s/%s/%s",
        strings.TrimRight(b.client.URL(), "/"),
        b.container,
        strings.TrimLeft(blobName, "/"),
    )
    return blobURL, nil
}

// Delete 刪除指定的 blob。
func (b *BlobClient) Delete(ctx context.Context, blobName string) error {
    _, err := b.client.DeleteBlob(ctx, b.container, blobName, nil)
    if err != nil {
        return fmt.Errorf("delete blob %s: %w", blobName, err)
    }
    return nil
}

// BlobNameFromURL 從完整 URL 反推出 blob 名稱（用於刪除操作）。
func (b *BlobClient) BlobNameFromURL(rawURL string) (string, error) {
    parsed, err := url.Parse(rawURL)
    if err != nil {
        return "", fmt.Errorf("parse blob url: %w", err)
    }

    // path = /&lt;container&gt;/&lt;blobName&gt;
    path := strings.TrimPrefix(parsed.Path, "/")
    prefix := b.container + "/"
    if !strings.HasPrefix(path, prefix) {
        return "", fmt.Errorf("blob url %q is not in container %q", rawURL, b.container)
    }

    blobName, err := url.PathUnescape(strings.TrimPrefix(path, prefix))
    if err != nil {
        return "", fmt.Errorf("decode blob name: %w", err)
    }
    return blobName, nil
}</code></pre>



<p class="wp-block-paragraph"><strong>Blob 命名策略：</strong></p>



<p class="wp-block-paragraph">建議使用層級路徑 + UUID 避免碰撞：</p>



<pre class="wp-block-code"><code>// items/{item_id}/{attachment_id}-{sanitized_filename}
blobName := fmt.Sprintf("items/%s/%s-%s", itemID, attachmentID, sanitizeFileName(originalName))</code></pre>



<p class="wp-block-paragraph"><code>sanitizeFileName</code> 應將中文、空格、特殊字元轉成 ASCII 安全字元：</p>



<pre class="wp-block-code"><code>func sanitizeFileName(name string) string {
    base := filepath.Base(name)
    cleaned := strings.Map(func(r rune) rune {
        switch {
        case r &gt;= 'a' &amp;&amp; r &lt;= 'z', r &gt;= 'A' &amp;&amp; r &lt;= 'Z', r &gt;= '0' &amp;&amp; r &lt;= '9':
            return r
        case r == '.', r == '-', r == '_':
            return r
        default:
            return '_'
        }
    }, base)
    if cleaned == "" {
        return "attachment"
    }
    return cleaned
}</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">資料庫查詢層 (Queries)</h3>



<pre class="wp-block-code"><code>// internal/item/queries.go（節錄附件相關部分）
package item

import (
    "context"
    "fmt"
    "strings"

    "github.com/google/uuid"
    "github.com/jmoiron/sqlx"
)

type Queries struct {
    db *sqlx.DB
}

// ListAttachments 列出某個 item 的所有附件。
func (q *Queries) ListAttachments(ctx context.Context, itemID uuid.UUID) (&#91;]Attachment, error) {
    var items &#91;]Attachment
    err := q.db.SelectContext(ctx, &amp;items, `
        SELECT CAST(id AS CHAR(36)) AS id,
               CAST(item_id AS CHAR(36)) AS item_id,
               file_name, blob_url, content_type, file_size, created_at
        FROM item_attachments
        WHERE item_id = @p1
        ORDER BY created_at ASC`,
        itemID,
    )
    if err != nil {
        return nil, fmt.Errorf("list attachments for item %s: %w", itemID, err)
    }
    if items == nil {
        items = &#91;]Attachment{}
    }
    return items, nil
}

// InsertAttachment 寫入一筆附件中繼資料。
func (q *Queries) InsertAttachment(ctx context.Context, a Attachment) error {
    _, err := q.db.ExecContext(ctx, `
        INSERT INTO item_attachments (id, item_id, file_name, blob_url, content_type, file_size)
        VALUES (@p1, @p2, @p3, @p4, @p5, @p6)`,
        a.ID, a.ItemID, a.FileName, a.BlobURL, a.ContentType, a.FileSize,
    )
    if err != nil {
        return fmt.Errorf("insert attachment %s: %w", a.ID, err)
    }
    return nil
}

// DeleteAttachment 刪除一筆附件中繼資料並回傳刪除前的資料（用來取得 BlobURL）。
func (q *Queries) DeleteAttachment(ctx context.Context, id uuid.UUID) (*Attachment, error) {
    var a Attachment
    // 先 SELECT 取得 blob_url，再 DELETE
    err := q.db.GetContext(ctx, &amp;a, `
        SELECT CAST(id AS CHAR(36)) AS id,
               CAST(item_id AS CHAR(36)) AS item_id,
               file_name, blob_url, content_type, file_size, created_at
        FROM item_attachments WHERE id = @p1`, id,
    )
    if err != nil {
        return nil, fmt.Errorf("get attachment %s: %w", id, err)
    }
    if _, err = q.db.ExecContext(ctx, `DELETE FROM item_attachments WHERE id = @p1`, id); err != nil {
        return nil, fmt.Errorf("delete attachment %s: %w", id, err)
    }
    return &amp;a, nil
}</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Service 層</h3>



<pre class="wp-block-code"><code>// internal/item/service.go（節錄附件相關部分）
package item

import (
    "context"
    "github.com/google/uuid"
)

type Querier interface {
    ListAttachments(ctx context.Context, itemID uuid.UUID) (&#91;]Attachment, error)
    InsertAttachment(ctx context.Context, a Attachment) error
    DeleteAttachment(ctx context.Context, id uuid.UUID) (*Attachment, error)
    // ... 其他方法
}

type Service struct {
    queries Querier
    logger  *zap.Logger
}

func (s *Service) ListAttachments(ctx context.Context, itemID uuid.UUID) (&#91;]Attachment, error) {
    return s.queries.ListAttachments(ctx, itemID)
}

func (s *Service) InsertAttachment(ctx context.Context, a Attachment) error {
    return s.queries.InsertAttachment(ctx, a)
}

func (s *Service) DeleteAttachment(ctx context.Context, id uuid.UUID) (*Attachment, error) {
    return s.queries.DeleteAttachment(ctx, id)
}</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">HTTP Handler</h3>



<p class="wp-block-paragraph">這是整個流程的核心，特別是 <code>UploadAttachment</code>：</p>



<pre class="wp-block-code"><code>// internal/item/handler.go
package item

import (
    "context"
    "encoding/json"
    "fmt"
    "mime/multipart"
    "net/http"
    "path/filepath"
    "strings"

    "github.com/yourorg/yourapp/internal/storage"
    "github.com/google/uuid"
)

type Store interface {
    ListAttachments(ctx context.Context, itemID uuid.UUID) (&#91;]Attachment, error)
    InsertAttachment(ctx context.Context, a Attachment) error
    DeleteAttachment(ctx context.Context, id uuid.UUID) (*Attachment, error)
}

type Handler struct {
    store      Store
    blobClient *storage.BlobClient
    // ... logger, validator, problemWriter 等
}

// UploadAttachment 處理 multipart/form-data 檔案上傳
// POST /api/items/{id}/attachments
func (h *Handler) UploadAttachment(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    itemID, ok := parseUUIDPathValue(r, w, "id")
    if !ok {
        return
    }

    // 1. 確認 Blob 客戶端已設定
    if h.blobClient == nil {
        http.Error(w, "attachments are not configured", http.StatusNotImplemented)
        return
    }

    // 2. 解析 multipart form（限制 32MB 記憶體，超過部分寫入臨時檔案）
    if err := r.ParseMultipartForm(32 &lt;&lt; 20); err != nil {
        http.Error(w, "invalid multipart form", http.StatusBadRequest)
        return
    }

    // 3. 收集所有上傳的檔案（支援同時上傳多個）
    files := collectFiles(r.MultipartForm)
    if len(files) == 0 {
        http.Error(w, "no files uploaded", http.StatusBadRequest)
        return
    }

    created := make(&#91;]Attachment, 0, len(files))

    for _, fileHeader := range files {
        file, err := fileHeader.Open()
        if err != nil {
            http.Error(w, "failed to open uploaded file", http.StatusInternalServerError)
            return
        }

        contentType := fileHeader.Header.Get("Content-Type")
        if contentType == "" {
            contentType = "application/octet-stream"
        }

        // 4. 產生唯一的 blob 名稱
        attachmentID := uuid.New()
        blobName := fmt.Sprintf(
            "items/%s/%s-%s",
            itemID,
            attachmentID,
            sanitizeFileName(fileHeader.Filename),
        )

        // 5. 上傳到 Azure Blob Storage
        blobURL, err := h.blobClient.Upload(ctx, blobName, file, contentType)
        _ = file.Close()
        if err != nil {
            http.Error(w, "failed to upload file", http.StatusInternalServerError)
            return
        }

        // 6. 寫入 DB（若失敗則回滾：刪除剛上傳的 blob）
        attachment := Attachment{
            ID:          attachmentID,
            ItemID:      itemID,
            FileName:    fileHeader.Filename,
            BlobURL:     blobURL,
            ContentType: contentType,
            FileSize:    fileHeader.Size,
        }
        if err := h.store.InsertAttachment(ctx, attachment); err != nil {
            _ = h.blobClient.Delete(ctx, blobName) // 補償操作：刪除已上傳的 blob
            http.Error(w, "failed to save attachment metadata", http.StatusInternalServerError)
            return
        }

        created = append(created, attachment)
    }

    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    _ = json.NewEncoder(w).Encode(created)
}

// DeleteAttachment 刪除附件（先刪 Blob，再刪 DB）
// DELETE /api/items/{id}/attachments/{attachmentId}
func (h *Handler) DeleteAttachment(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    itemID, ok := parseUUIDPathValue(r, w, "id")
    if !ok {
        return
    }
    attachmentID, ok := parseUUIDPathValue(r, w, "attachmentId")
    if !ok {
        return
    }

    // 1. 先取得附件資料（包含 blob_url）
    attachments, err := h.store.ListAttachments(ctx, itemID)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    target := findAttachment(attachments, attachmentID)
    if target == nil {
        http.Error(w, "attachment not found", http.StatusNotFound)
        return
    }

    // 2. 刪除 Blob Storage 中的實體檔案
    if h.blobClient != nil &amp;&amp; target.BlobURL != "" {
        blobName, err := h.blobClient.BlobNameFromURL(target.BlobURL)
        if err == nil {
            _ = h.blobClient.Delete(ctx, blobName) // 即使失敗也繼續刪除 DB 記錄
        }
    }

    // 3. 刪除 DB 中繼資料
    deleted, err := h.store.DeleteAttachment(ctx, attachmentID)
    if err != nil {
        http.Error(w, "failed to delete attachment", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    _ = json.NewEncoder(w).Encode(deleted)
}

// ListAttachments 列出某 item 的所有附件
// GET /api/items/{id}/attachments
func (h *Handler) ListAttachments(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    itemID, ok := parseUUIDPathValue(r, w, "id")
    if !ok {
        return
    }

    items, err := h.store.ListAttachments(ctx, itemID)
    if err != nil {
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    _ = json.NewEncoder(w).Encode(items)
}

// --- 輔助函式 ---

func parseUUIDPathValue(r *http.Request, w http.ResponseWriter, key string) (uuid.UUID, bool) {
    id, err := uuid.Parse(r.PathValue(key))
    if err != nil {
        http.Error(w, fmt.Sprintf("invalid %s", key), http.StatusBadRequest)
        return uuid.Nil, false
    }
    return id, true
}

func collectFiles(form *multipart.Form) &#91;]*multipart.FileHeader {
    if form == nil {
        return nil
    }
    var files &#91;]*multipart.FileHeader
    for _, entries := range form.File {
        files = append(files, entries...)
    }
    return files
}

func findAttachment(items &#91;]Attachment, id uuid.UUID) *Attachment {
    for i := range items {
        if items&#91;i].ID == id {
            return &amp;items&#91;i]
        }
    }
    return nil
}</code></pre>



<p class="wp-block-paragraph"><strong>刪除流程的容錯設計：</strong></p>



<pre class="wp-block-code"><code>刪除 Blob Storage ──→ 成功 ──→ 刪除 DB 記錄 ──→ 完成
                  └──→ 失敗 ──→ 仍繼續刪除 DB 記錄（孤立 blob，可定期清理）

上傳 Blob Storage ──→ 成功 ──→ 寫入 DB 記錄 ──→ 成功 ──→ 完成
                                              └──→ 失敗 ──→ 補償刪除 blob（避免孤立檔案）</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">路由註冊</h3>



<pre class="wp-block-code"><code>// cmd/main.go（節錄）
import (
    "github.com/yourorg/yourapp/internal/item"
    "github.com/yourorg/yourapp/internal/storage"
)

// 初始化 Blob 客戶端（connectionString 空白時回傳 nil，不影響啟動）
blobClient, err := storage.NewBlobClient(
    cfg.AzureStorage.ConnectionString,
    cfg.AzureStorage.ContainerName,
)
if err != nil {
    logger.Fatal("Failed to initialize Azure Blob Storage client", zap.Error(err))
}

itemQueries := item.NewQueries(dbPool)
itemService := item.NewService(logger, itemQueries)
itemHandler := item.NewHandler(logger, validator, problemWriter, itemService, blobClient)

// 公開路由（無需鑑權）
mux.HandleFunc("GET /api/items/{id}/attachments", itemHandler.ListAttachments)

// 需要管理員角色的路由
mux.HandleFunc("POST /api/items/{id}/attachments",
    authMiddleware.HandlerFunc(
        roleMiddleware.RequireRoles("admin", "super_admin")(itemHandler.UploadAttachment),
    ),
)
mux.HandleFunc("DELETE /api/items/{id}/attachments/{attachmentId}",
    authMiddleware.HandlerFunc(
        roleMiddleware.RequireRoles("admin", "super_admin")(itemHandler.DeleteAttachment),
    ),
)</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">設定管理</h3>



<pre class="wp-block-code"><code>// internal/config/config.go（節錄）

type AzureStorage struct {
    ConnectionString string `yaml:"connection_string" envconfig:"AZURE_STORAGE_CONNECTION_STRING"`
    ContainerName    string `yaml:"container_name"    envconfig:"AZURE_STORAGE_CONTAINER_NAME"`
}

type Config struct {
    // ...
    AzureStorage AzureStorage `yaml:"azure_storage"`
}</code></pre>



<p class="wp-block-paragraph">環境變數（<code>.env</code> 本機開發）：</p>



<pre class="wp-block-code"><code>AZURE_STORAGE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=mystorageaccount;AccountKey=xxx;...
AZURE_STORAGE_CONTAINER_NAME=my-container</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>本機開發提示</strong>：若不需要實際上傳，直接不設定 <code>AZURE_STORAGE_CONNECTION_STRING</code>，<br><code>NewBlobClient</code> 會回傳 <code>nil</code>，Handler 中的 <code>if h.blobClient == nil</code> 判斷會回傳 <code>501 Not Implemented</code>，不影響其他功能開發。</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">前端實作</h2>



<h3 class="wp-block-heading">型別定義</h3>



<pre class="wp-block-code"><code>// src/types/item.ts

export interface ItemFile {
    id: string;
    item_id: string;
    file_name: string;
    blob_url: string;
    content_type: string;
    file_size: number;
    created_at: string;
}

export interface Item {
    id: string;
    title: string;
    created_at: string;
    updated_at: string;
    attachments: ItemFile&#91;]; // 由 API list 回應中直接包含，避免 N+1
}</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">API 呼叫層</h3>



<pre class="wp-block-code"><code>// src/api/itemAttachments.ts

const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '';

export async function uploadAttachment(itemId: string, file: File): Promise&lt;ItemFile&#91;]&gt; {
    const formData = new FormData();
    formData.append('file', file);

    const res = await fetch(`${API_BASE}/api/items/${itemId}/attachments`, {
        method: 'POST',
        credentials: 'include', // 傳送 cookie（session / JWT）
        body: formData,
        // 注意：不要手動設定 Content-Type，讓瀏覽器自動產生含 boundary 的 multipart header
    });

    if (!res.ok) {
        const text = await res.text();
        throw new Error(text || `Upload failed: ${res.status}`);
    }

    return res.json() as Promise&lt;ItemFile&#91;]&gt;;
}

export async function deleteAttachment(itemId: string, attachmentId: string): Promise&lt;void&gt; {
    const res = await fetch(`${API_BASE}/api/items/${itemId}/attachments/${attachmentId}`, {
        method: 'DELETE',
        credentials: 'include',
    });

    if (!res.ok) {
        throw new Error(`Delete failed: ${res.status}`);
    }
}</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> <strong>常見錯誤</strong>：手動設定 <code>'Content-Type': 'multipart/form-data'</code> 會導致缺少 <code>boundary</code> 參數，<br>後端無法解析。使用 <code>FormData</code> 時，永遠讓瀏覽器自動設定 <code>Content-Type</code>。</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">管理介面元件（上傳 / 刪除）</h3>



<p class="wp-block-paragraph">使用 <a href="https://mantine.dev/x/dropzone/">Mantine Dropzone</a> 建立拖放上傳介面：</p>



<pre class="wp-block-code"><code>npm install @mantine/dropzone</code></pre>



<pre class="wp-block-code"><code>// src/components/AttachmentManager.tsx
import { useState } from 'react';
import { Anchor, Group, Stack, Text, ActionIcon } from '@mantine/core';
import { Dropzone, type FileWithPath } from '@mantine/dropzone';
import { notifications } from '@mantine/notifications';
import { IconFile, IconTrash, IconUpload, IconX } from '@tabler/icons-react';
import { uploadAttachment, deleteAttachment } from '@/api/itemAttachments';
import type { ItemFile } from '@/types/item';

const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB

interface Props {
    itemId: string;
    attachments: ItemFile&#91;];
    onChanged: (updated: ItemFile&#91;]) =&gt; void; // 附件變動時通知父元件更新
    readOnly?: boolean;
}

function formatFileSize(bytes: number): string {
    if (bytes &lt; 1024) return `${bytes} B`;
    if (bytes &lt; 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

export function AttachmentManager({ itemId, attachments, onChanged, readOnly }: Props) {
    const &#91;uploading, setUploading] = useState(false);

    const handleDrop = async (files: FileWithPath&#91;]) =&gt; {
        setUploading(true);
        try {
            for (const file of files) {
                const newFiles = await uploadAttachment(itemId, file);
                // 每次上傳後更新附件列表（後端回傳新增的附件）
                onChanged(&#91;...attachments, ...newFiles]);
            }
            notifications.show({ color: 'green', message: '檔案上傳成功' });
        } catch (e) {
            notifications.show({ color: 'red', message: `上傳失敗：${e instanceof Error ? e.message : '未知錯誤'}` });
        } finally {
            setUploading(false);
        }
    };

    const handleDelete = async (file: ItemFile) =&gt; {
        try {
            await deleteAttachment(itemId, file.id);
            onChanged(attachments.filter(a =&gt; a.id !== file.id));
            notifications.show({ color: 'green', message: '附件已刪除' });
        } catch {
            notifications.show({ color: 'red', message: '刪除失敗' });
        }
    };

    return (
        &lt;Stack gap='sm'&gt;
            {/* 已上傳的附件列表 */}
            {attachments.map((file) =&gt; (
                &lt;Group key={file.id} gap='xs' align='center' wrap='nowrap'&gt;
                    &lt;IconFile size={14} /&gt;
                    &lt;Anchor href={file.blob_url} target='_blank' rel='noopener noreferrer' size='sm' style={{ flex: 1 }}&gt;
                        {file.file_name}
                    &lt;/Anchor&gt;
                    &lt;Text size='xs' c='dimmed'&gt;{formatFileSize(file.file_size)}&lt;/Text&gt;
                    {!readOnly &amp;&amp; (
                        &lt;ActionIcon
                            color='red'
                            variant='subtle'
                            size='sm'
                            onClick={() =&gt; void handleDelete(file)}
                        &gt;
                            &lt;IconTrash size={14} /&gt;
                        &lt;/ActionIcon&gt;
                    )}
                &lt;/Group&gt;
            ))}

            {/* 拖放上傳區域（僅管理員可見） */}
            {!readOnly &amp;&amp; (
                &lt;Dropzone
                    onDrop={(files) =&gt; void handleDrop(files)}
                    onReject={(rejections) =&gt; {
                        rejections.forEach(r =&gt;
                            notifications.show({ color: 'red', message: `${r.file.name} 無法上傳` })
                        );
                    }}
                    maxSize={MAX_FILE_SIZE}
                    loading={uploading}
                    accept={&#91;
                        'application/pdf',
                        'application/msword',
                        'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
                        'image/png',
                        'image/jpeg',
                    ]}
                &gt;
                    &lt;Group justify='center' gap='xs' style={{ pointerEvents: 'none' }}&gt;
                        &lt;Dropzone.Accept&gt;&lt;IconUpload size={20} /&gt;&lt;/Dropzone.Accept&gt;
                        &lt;Dropzone.Reject&gt;&lt;IconX size={20} color='red' /&gt;&lt;/Dropzone.Reject&gt;
                        &lt;Dropzone.Idle&gt;&lt;IconUpload size={20} /&gt;&lt;/Dropzone.Idle&gt;
                        &lt;Stack gap={2}&gt;
                            &lt;Text size='sm' fw={600}&gt;拖放檔案至此，或點擊上傳&lt;/Text&gt;
                            &lt;Text size='xs' c='dimmed'&gt;PDF、Word、圖片，最大 10 MB&lt;/Text&gt;
                        &lt;/Stack&gt;
                    &lt;/Group&gt;
                &lt;/Dropzone&gt;
            )}
        &lt;/Stack&gt;
    );
}</code></pre>



<p class="wp-block-paragraph"><strong>在新增表單中處理「尚未建立的 item」：</strong></p>



<pre class="wp-block-code"><code>// 新增模式：先暫存 pending files，待 item 建立後再批次上傳
const &#91;pendingFiles, setPendingFiles] = useState&lt;File&#91;]&gt;(&#91;]);

const handleCreateAndUpload = async () =&gt; {
    // 1. 建立 item
    const saved = await createItem(formData);

    // 2. 上傳所有暫存的附件
    if (pendingFiles.length &gt; 0) {
        for (const file of pendingFiles) {
            await uploadAttachment(saved.id, file);
        }
    }

    // 完成
};

// 新增模式的 Dropzone：只加入暫存佇列
&lt;Dropzone
    onDrop={(files) =&gt; setPendingFiles(prev =&gt; &#91;...prev, ...files])}
    // ...
&gt;</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">公開展示元件（下載連結）</h3>



<p class="wp-block-paragraph">在公開頁面顯示附件清單，使用者可以直接點擊下載：</p>



<pre class="wp-block-code"><code>// src/components/ItemCard.tsx
import { Anchor, Group, Stack, Text } from '@mantine/core';
import { IconFile } from '@tabler/icons-react';
import type { Item } from '@/types/item';

function formatFileSize(bytes: number): string {
    if (bytes &lt; 1024) return `${bytes} B`;
    if (bytes &lt; 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
    return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

export function ItemCard({ item }: { item: Item }) {
    return (
        &lt;Stack gap='xs'&gt;
            &lt;Text fw={700}&gt;{item.title}&lt;/Text&gt;

            {/* 附件區塊（若有附件才顯示） */}
            {item.attachments &amp;&amp; item.attachments.length &gt; 0 &amp;&amp; (
                &lt;Stack gap={4} mt={4}&gt;
                    {item.attachments.map((file) =&gt; (
                        &lt;Group key={file.id} gap={6} align='center' wrap='nowrap'&gt;
                            &lt;IconFile size={13} color='var(--mantine-color-blue-5)' style={{ flexShrink: 0 }} /&gt;
                            &lt;Anchor
                                href={file.blob_url}
                                target='_blank'
                                rel='noopener noreferrer'
                                size='xs'
                            &gt;
                                {file.file_name}
                            &lt;/Anchor&gt;
                            &lt;Text size='xs' c='dimmed' style={{ flexShrink: 0 }}&gt;
                                ({formatFileSize(file.file_size)})
                            &lt;/Text&gt;
                        &lt;/Group&gt;
                    ))}
                &lt;/Stack&gt;
            )}
        &lt;/Stack&gt;
    );
}</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">效能考量：N+1 查詢問題</h2>



<h3 class="wp-block-heading">問題描述</h3>



<p class="wp-block-paragraph">如果 List API 只回傳 item 基本資料，前端需要對每個 item 各自呼叫一次附件 API：</p>



<pre class="wp-block-code"><code>GET /api/items          → 取得 10 筆 item
GET /api/items/1/attachments  → 取得第 1 筆的附件
GET /api/items/2/attachments  → 取得第 2 筆的附件
...（共 11 次 API 呼叫）</code></pre>



<h3 class="wp-block-heading">解決方案：後端 Batch Fetch</h3>



<p class="wp-block-paragraph">在後端的 <code>List()</code> 函式中，用<strong>一次額外查詢</strong>取得所有相關附件，再在記憶體中組合：</p>



<pre class="wp-block-code"><code>func (q *Queries) List(ctx context.Context) (&#91;]Item, error) {
    // 1. 先取得所有 items
    var items &#91;]Item
    if err := q.db.SelectContext(ctx, &amp;items, `SELECT ... FROM items ORDER BY ...`); err != nil {
        return nil, err
    }
    if len(items) == 0 {
        return items, nil
    }

    // 2. 建立 IN 子句的參數（@p1, @p2, ...）
    idPlaceholders := make(&#91;]string, len(items))
    idArgs := make(&#91;]any, len(items))
    for i, item := range items {
        idPlaceholders&#91;i] = fmt.Sprintf("@p%d", i+1)
        idArgs&#91;i] = item.ID
    }

    // 3. 一次查詢所有相關附件（僅 2 次 DB 往返，不管 items 有多少筆）
    var attachments &#91;]Attachment
    attachQuery := fmt.Sprintf(`
        SELECT ... FROM item_attachments
        WHERE item_id IN (%s)
        ORDER BY item_id, created_at ASC`,
        strings.Join(idPlaceholders, ","),
    )
    if err := q.db.SelectContext(ctx, &amp;attachments, attachQuery, idArgs...); err != nil {
        return nil, err
    }

    // 4. 在記憶體中建立 map，並填入各 item 的 Attachments 欄位
    attachByItem := make(map&#91;string]&#91;]Attachment, len(items))
    for _, a := range attachments {
        key := a.ItemID.String()
        attachByItem&#91;key] = append(attachByItem&#91;key], a)
    }
    for i, item := range items {
        if files, ok := attachByItem&#91;item.ID.String()]; ok {
            items&#91;i].Attachments = files
        } else {
            items&#91;i].Attachments = &#91;]Attachment{}
        }
    }

    return items, nil
}</code></pre>



<p class="wp-block-paragraph"><strong>效能比較：</strong></p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>方法</th><th>DB 查詢次數</th><th>說明</th></tr></thead><tbody><tr><td>N+1（前端各自請求）</td><td>1 + N 次 API × 1 DB</td><td>10 筆 item → 11 次 API、11 次 DB</td></tr><tr><td>N+1（後端各自查詢）</td><td>1 + N 次 DB</td><td>10 筆 item → 11 次 DB</td></tr><tr><td>Batch Fetch（本方案）</td><td>2 次 DB</td><td>不管幾筆，固定 2 次 DB 往返</td></tr></tbody></table></figure>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">部署至 Azure Container Apps</h2>



<h3 class="wp-block-heading">1. 將 Connection String 存為 Secret</h3>



<pre class="wp-block-code"><code>az containerapp secret set \
  --name my-backend-app \
  --resource-group my-resource-group \
  --secrets "azure-storage-conn=DefaultEndpointsProtocol=https;AccountName=mystorageaccount;AccountKey=xxx;..."</code></pre>



<h3 class="wp-block-heading">2. 以環境變數參照 Secret</h3>



<pre class="wp-block-code"><code>az containerapp update \
  --name my-backend-app \
  --resource-group my-resource-group \
  --set-env-vars "AZURE_STORAGE_CONNECTION_STRING=secretref:azure-storage-conn" \
  --set-env-vars "AZURE_STORAGE_CONTAINER_NAME=my-container"</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><code>secretref:</code> 前綴告訴 Azure Container Apps 從 secret 讀取值，而非當成明文。<br>這樣 Connection String 不會出現在部署歷史或 ARM template 的純文字中。</p>
</blockquote>



<h3 class="wp-block-heading">3. 確認部署後的設定</h3>



<pre class="wp-block-code"><code>az containerapp show \
  --name my-backend-app \
  --resource-group my-resource-group \
  --query "properties.template.containers&#91;0].env" \
  --output table</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">常見問題與除錯</h2>



<h3 class="wp-block-heading">Q1：上傳後 blob_url 無法存取（403 Forbidden）</h3>



<p class="wp-block-paragraph"><strong>原因</strong>：容器的公開存取層級未正確設定。</p>



<p class="wp-block-paragraph"><strong>排查步驟：</strong></p>



<pre class="wp-block-code"><code># 確認帳戶層級是否啟用 Allow Blob Anonymous Access
az storage account show \
  --name mystorageaccount \
  --query "allowBlobPublicAccess"

# 確認容器層級的公開存取設定
az storage container show-permission \
  --name my-container \
  --account-name mystorageaccount \
  --account-key "&lt;key&gt;"
# 應顯示 "publicAccess": "blob"</code></pre>



<p class="wp-block-paragraph"><strong>修正：</strong></p>



<pre class="wp-block-code"><code># 先啟用帳戶層級
az storage account update --name mystorageaccount --allow-blob-public-access true

# 再設定容器層級
az storage container set-permission \
  --name my-container \
  --account-name mystorageaccount \
  --account-key "&lt;key&gt;" \
  --public-access blob</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Q2：後端上傳時回傳 <code>containerNotFound</code> 或容器已存在但仍報錯</h3>



<p class="wp-block-paragraph"><strong>原因</strong>：<code>NewBlobClient</code> 在啟動時會嘗試建立容器，若帳戶層級還未啟用公開存取，<br>容器會被建立成私有的。之後再用 <code>az storage container set-permission</code> 設定時，<br>若使用的是 <code>--connection-string</code> 而非 <code>--account-key</code>，可能因連線字串格式差異導致找不到容器。</p>



<p class="wp-block-paragraph"><strong>解決方式</strong>：統一使用 <code>--account-name</code> + <code>--account-key</code> 參數：</p>



<pre class="wp-block-code"><code>az storage container set-permission \
  --name my-container \
  --account-name mystorageaccount \
  --account-key "$(az storage account keys list --account-name mystorageaccount --query '&#91;0].value' -o tsv)" \
  --public-access blob</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Q3：上傳後 DB 寫入失敗，但 Blob 已存在（孤立檔案）</h3>



<p class="wp-block-paragraph"><strong>原因</strong>：<code>InsertAttachment</code> 失敗前 <code>blobClient.Delete</code> 呼叫也失敗（例如網路短暫斷線）。</p>



<p class="wp-block-paragraph"><strong>建議處理方式：</strong></p>



<ol class="wp-block-list">
<li>短期：記錄 <code>blobName</code> 到錯誤 log，人工清理</li>



<li>長期：建立定期清理任務，查找 DB 中不存在對應記錄的 blob：</li>
</ol>



<ul class="wp-block-list">
<li>列出 Blob Storage 中所有 blob</li>



<li>比對 DB 中的 <code>blob_url</code> 欄位</li>



<li>刪除孤立的 blob</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Q4：前端上傳大檔案時逾時</h3>



<p class="wp-block-paragraph"><strong>後端限制</strong>（Go http.Server）：</p>



<pre class="wp-block-code"><code>server := &amp;http.Server{
    ReadTimeout:  30 * time.Second,  // 預設可能太短
    WriteTimeout: 30 * time.Second,
    // 上傳大檔案時應調整：
    ReadTimeout:  5 * time.Minute,
}</code></pre>



<p class="wp-block-paragraph"><strong>Azure Container Apps 限制</strong>：<br>Container Apps 的 HTTP 請求預設有 240 秒逾時，對於大型檔案需注意。</p>



<p class="wp-block-paragraph"><strong>建議</strong>：檔案大小限制設在前端（<code>maxSize</code> prop）與後端（<code>ParseMultipartForm</code> 的記憶體限制）雙重把關：</p>



<pre class="wp-block-code"><code>// 最大允許 50MB（超過部分寫入臨時檔案）
if err := r.ParseMultipartForm(50 &lt;&lt; 20); err != nil {
    http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
    return
}</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Q5：Go 模組安裝 Azure SDK</h3>



<pre class="wp-block-code"><code>go get github.com/Azure/azure-sdk-for-go/sdk/storage/azblob@latest</code></pre>



<p class="wp-block-paragraph">在 <code>go.mod</code> 中確認版本：</p>



<pre class="wp-block-code"><code>require (
    github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.x.x
)</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">小結</h2>



<p class="wp-block-paragraph">本文涵蓋的完整實作流程：</p>



<ol class="wp-block-list">
<li><strong>Azure 設定</strong>：建立 Storage Account、啟用公開 blob 存取、取得 Connection String、部署時存為 Container Apps Secret</li>



<li><strong>後端</strong>：<code>BlobClient</code> 封裝上傳/刪除/URL解析；Handler 處理 multipart 上傳、DB 寫入、補償刪除；DB 只存中繼資料</li>



<li><strong>前端</strong>：<code>FormData</code> 上傳（不手動設 Content-Type）；Mantine Dropzone 拖放介面；公開頁面以 <code>blob_url</code> 直接提供下載連結</li>



<li><strong>效能</strong>：用 Batch Fetch 解決 N+1，List API 在 2 次 DB 查詢內回傳完整資料（含附件）</li>



<li><strong>容錯</strong>：上傳失敗時補償刪除 Blob；刪除失敗時繼續刪除 DB 記錄並記錄 log</li>
</ol>



<p class="wp-block-paragraph">這套模式適用於任何需要「主體 + 附件」關聯的功能（公告、申請表、報告等），可依需求調整鑑權邏輯與允許的檔案類型。</p>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/05/azure-blob-storage-attachment/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Azure SQL 資料庫權限管理：不搶 Admin，照樣能下 SQL 指令</title>
		<link>https://stackoverflow.max-everyday.com/2026/05/azure-sql-admin-sql/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/05/azure-sql-admin-sql/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Wed, 20 May 2026 07:18:17 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[azure]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8450</guid>

					<description><![CDATA[適合對象：剛接觸 Azure SQL Datab...]]></description>
										<content:encoded><![CDATA[
<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>適合對象</strong>：剛接觸 Azure SQL Database 的開發者、需要協作管理資料庫權限的團隊</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">前言：「我只是要查資料，為什麼要給我 Admin？」</h2>



<p class="wp-block-paragraph">在 Azure SQL Database 的世界裡，常常會遇到這樣的情境：</p>



<ul class="wp-block-list">
<li>同事 Alice 是團隊的 <strong>SQL Entra Admin（系統管理員）</strong></li>



<li>你只是要對某個測試資料庫跑幾個 SQL 腳本</li>



<li>有人建議「直接把你也設成 Entra Admin 就好」</li>
</ul>



<p class="wp-block-paragraph">等等！這個做法雖然快，卻是個<strong>大地雷</strong>。本篇文章將說明正確做法，讓你在不動到管理員權限的前提下，順利取得資料庫存取能力。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">問題核心：SQL Entra Admin 的影響範圍</h2>



<p class="wp-block-paragraph">Azure SQL Server 的 <strong>Entra Admin（Azure AD 管理員）</strong> 是 <strong>整個 SQL Server 實例層級</strong> 的最高管理員，每個 SQL Server <strong>只能設定一個</strong>（可以是使用者或群組）。</p>



<p class="wp-block-paragraph">如果把你也加入成為 Entra Admin，會發生什麼事？</p>



<ol class="wp-block-list">
<li><strong>原本的 Admin（Alice）的權限會被覆蓋</strong>，她可能因此失去管理員身份。</li>



<li>你拿到的是遠超出需求的<strong>全站最高權限</strong>，違反資安最小授權原則（Principle of Least Privilege）。</li>



<li>未來權限管理會更混亂。</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">正確解法：請 Entra Admin 幫你在資料庫內建立使用者</h2>



<p class="wp-block-paragraph"><strong>最標準、最安全的做法</strong>是：請擁有 Admin 的 Alice，在目標資料庫內幫你建立一個 <strong>資料庫層級的使用者（Database User）</strong>，並只賦予你實際需要的權限。</p>



<h3 class="wp-block-heading">步驟一：Alice 執行 SQL 腳本建立你的帳號</h3>



<p class="wp-block-paragraph">請 Alice 登入 <strong>Azure Portal → SQL Server → 目標資料庫（例如：<code>myapp-staging</code>）→ 查詢編輯器（Query Editor）</strong>，執行以下指令：</p>



<pre class="wp-block-code"><code>-- 步驟 1：為開發者的 Entra ID 帳號建立資料庫使用者
CREATE USER &#91;developer@example.onmicrosoft.com] FROM EXTERNAL PROVIDER;
GO

-- 步驟 2：依需求選擇一種方案賦予權限

-- &#x2705; 方案 A：唯讀（只查資料，不修改）
ALTER ROLE db_datareader ADD MEMBER &#91;developer@example.onmicrosoft.com];
GO

-- &#x2705; 方案 B：讀寫（查詢、新增、修改、刪除資料）
ALTER ROLE db_datareader ADD MEMBER &#91;developer@example.onmicrosoft.com];
ALTER ROLE db_datawriter ADD MEMBER &#91;developer@example.onmicrosoft.com];
GO

-- &#x26a0; 方案 C：資料庫擁有者（可改 Schema、建刪資料表，謹慎使用）
-- ALTER ROLE db_owner ADD MEMBER &#91;developer@example.onmicrosoft.com];
-- GO</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>提醒</strong>：<code>developer@example.onmicrosoft.com</code> 請替換為實際的 Azure Entra ID（Azure AD）帳號 Email。</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">步驟二：你自己登入資料庫下指令</h3>



<p class="wp-block-paragraph">Alice 執行完畢後，你就可以用<strong>自己的帳號</strong>操作資料庫，完全不需要 Admin 權限：</p>



<ol class="wp-block-list">
<li>前往 <strong>Azure Portal → SQL Server → 目標資料庫</strong></li>



<li>點選左側選單的「<strong>查詢編輯器 (預覽)</strong>」</li>



<li>驗證方式選擇「<strong>以您的目前使用者身份繼續</strong>」（Entra ID 整合驗證）</li>



<li>登入後，即可在瀏覽器內直接執行 SQL 指令 <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f389.png" alt="🎉" class="wp-smiley" style="height: 1em; max-height: 1em;" /></li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">資料庫角色權限對照表</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>角色名稱</th><th>可執行操作</th><th>適合情境</th></tr></thead><tbody><tr><td><code>db_datareader</code></td><td><code>SELECT</code>（唯讀）</td><td>只需拉報表、查資料，不更動任何內容</td></tr><tr><td><code>db_datawriter</code></td><td><code>INSERT</code> / <code>UPDATE</code> / <code>DELETE</code></td><td>維護資料內容、執行資料修正腳本</td></tr><tr><td><code>db_owner</code></td><td>資料庫內所有操作（含 Schema 異動）</td><td>需要建立資料表、修改欄位等結構性工作</td></tr></tbody></table></figure>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>建議</strong>：日常開發大多使用 <code>db_datareader + db_datawriter</code> 的組合，已足夠執行絕大多數的 SQL 操作。<code>db_owner</code> 應僅在確實需要異動資料庫結構時才賦予。</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">整體架構示意</h2>



<pre class="wp-block-code"><code>Azure SQL Server（myapp-sqlsvr）
│
├── Entra Admin：alice@example.com（整個 SQL Server 的管理員）
│
└── myapp-staging 資料庫
    ├── Database User：alice@example.com（繼承 Admin 權限）
    └── Database User：developer@example.com（db_datareader + db_datawriter）
                                              ↑
                                    這才是你應該有的層級！</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">小結</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>做法</th><th>安全性</th><th>對現有 Admin 的影響</th></tr></thead><tbody><tr><td><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/274c.png" alt="❌" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 把開發者也設為 SQL Entra Admin</td><td>危險，權限過大</td><td>可能覆蓋現有 Admin</td></tr><tr><td><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2705.png" alt="✅" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 在資料庫內建立使用者並賦予角色</td><td>安全，符合最小授權</td><td>完全不影響</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">只要讓 <strong>擁有 Admin 的人執行一次 <code>CREATE USER</code> 腳本</strong>，你就能以最小、最安全的權限獨立作業，也不會干擾到其他管理員的設定。</p>



<p class="wp-block-paragraph">這才是 Azure SQL 資料庫多人協作的標準做法。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph"><em>Tags: <code>Azure</code> <code>SQL Database</code> <code>Entra ID</code> <code>Azure AD</code> <code>資料庫權限</code> <code>db_owner</code> <code>db_datareader</code></em></p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<p class="wp-block-paragraph">如果有需要搶走 Entra Admin 可以使用這個 power shell script:</p>



<pre class="wp-block-code"><code># set-sql-entra-admin.ps1
# Usage: .\set-sql-entra-admin.ps1 -UserEmail lisa@example.onmicrosoft.com

param(
    &#91;Parameter(Mandatory=$true)]
    &#91;string]$UserEmail
)

$ResourceGroup = "rg-data-stg-jpe-001"
$ServerName    = "your-sqlsvr-name"

Write-Host "Looking up Object ID for: $UserEmail ..."
$ObjectId = az ad user show --id $UserEmail --query id -o tsv 2>&amp;1

if ($LASTEXITCODE -ne 0) {
    Write-Error "User not found: $UserEmail"
    exit 1
}

Write-Host "Setting Entra admin to: $UserEmail (OID: $ObjectId)"
az sql server ad-admin create `
    --resource-group $ResourceGroup `
    --server $ServerName `
    --display-name $UserEmail `
    --object-id $ObjectId

if ($LASTEXITCODE -eq 0) {
    Write-Host "Done! Entra admin is now: $UserEmail"
} else {
    Write-Error "Failed to set Entra admin."
    exit 1
}
</code></pre>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/05/azure-sql-admin-sql/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>如何確認 Azure Container Apps 上跑的是最新程式碼？</title>
		<link>https://stackoverflow.max-everyday.com/2026/05/azure-container-apps-ver-check/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/05/azure-container-apps-ver-check/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Tue, 19 May 2026 08:49:48 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[azure]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8448</guid>

					<description><![CDATA[標籤：Azure、Container Apps、...]]></description>
										<content:encoded><![CDATA[
<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">標籤：Azure、Container Apps、Go、React、DevOps、部署驗證</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">前言</h2>



<p class="wp-block-paragraph">你有沒有遇過這種狀況：</p>



<ol class="wp-block-list">
<li>修了一個 bug，<code>git push</code>，手動觸發部署。</li>



<li>等了三分鐘，部署顯示「成功」。</li>



<li>開瀏覽器一看…… <strong>bug 還在</strong>。</li>



<li>狐疑地 <code>docker logs</code> 看一下，發現 container 跑的根本是上個版本的 image。</li>
</ol>



<p class="wp-block-paragraph">這就是我們踩過的坑。本文記錄我們如何系統性地解決這個問題——<strong>在 build 時把 git commit SHA 嵌入程式，透過 HTTP endpoint 暴露出來，讓部署後的驗證變成一行 curl 指令</strong>。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">問題根源：三個隱形的快取陷阱</h2>



<h3 class="wp-block-heading">陷阱 1：Go binary 沒有重新編譯</h3>



<p class="wp-block-paragraph">我們的 Dockerfile 長這樣：</p>



<pre class="wp-block-code"><code># backend/Dockerfile
FROM alpine:3.19
COPY bin/backend /app/backend
ENTRYPOINT &#91;"/app/backend"]</code></pre>



<p class="wp-block-paragraph">Dockerfile 直接把 <strong>預先編譯好的 binary</strong> 複製進 image。<br>如果 <code>go build</code> 沒有在 <code>docker build</code> 之前執行，那麼：</p>



<pre class="wp-block-code"><code>修改 Go 原始碼 → docker build → 推送 image → 部署
                ↑ bin/backend 是舊的！</code></pre>



<p class="wp-block-paragraph"><strong>image 裡永遠是舊的 binary</strong>，source code 的修改完全無效。</p>



<h3 class="wp-block-heading">陷阱 2：React <code>dist/</code> 沒有重新 build</h3>



<p class="wp-block-paragraph">前端的 Dockerfile 同理：</p>



<pre class="wp-block-code"><code># frontend/Dockerfile
FROM nginx:alpine
COPY dist/ /usr/share/nginx/html</code></pre>



<p class="wp-block-paragraph">如果 <code>npm run build</code> 沒有在 <code>docker build</code> 之前執行，<code>dist/</code> 是舊的，React 改動不生效。</p>



<h3 class="wp-block-heading">陷阱 3：Docker layer cache</h3>



<p class="wp-block-paragraph">即使你真的重新執行了 <code>go build</code> 和 <code>npm run build</code>，Docker build cache 也可能讓你以為 image 更新了，實際上卻是沿用舊 layer。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">解法：嵌入 Git Commit SHA</h2>



<h3 class="wp-block-heading">概念</h3>



<pre class="wp-block-code"><code>build 時 ──► git rev-parse --short HEAD ──► 嵌入 binary
                                              └─► /api/version 回傳
                                                    │
部署後 ─────────────────────────────────────────────► curl /api/version
                                                       └─► 比對 commit SHA &#x2705; 或 &#x26a0;</code></pre>



<p class="wp-block-paragraph">只要 <code>/api/version</code> 回傳的 commit hash 和 <code>git rev-parse --short HEAD</code> 一致，就能 100% 確認 Azure 上跑的是最新程式碼。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">實作步驟</h2>



<h3 class="wp-block-heading">Step 1：在 Go 主程式宣告版本變數</h3>



<p class="wp-block-paragraph"><code>cmd/main.go</code>：</p>



<pre class="wp-block-code"><code>package main

// 這些變數在 go build 時由 -ldflags 注入
// 預設值為 "dev"，方便本地開發
var (
    AppName    = "my-backend"
    Version    = "no-version"
    BuildTime  = "no-build-time"
    CommitHash = "no-commit-hash"
)</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>為什麼用 <code>var</code> 不用 <code>const</code>？</strong><br><code>-ldflags "-X package.VarName=value"</code> 只能注入 <code>var</code>，無法注入 <code>const</code>。</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Step 2：註冊 <code>/api/version</code> endpoint</h3>



<p class="wp-block-paragraph">在 <code>cmd/main.go</code> 的路由設定區塊加入：</p>



<pre class="wp-block-code"><code>import (
    "encoding/json"
    "net/http"
    "time"
)

// 放在其他路由旁邊，不需要 auth middleware
mux.HandleFunc("GET /api/version", func(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map&#91;string]string{
        "app":      AppName,
        "version":  Version,
        "commit":   CommitHash,
        "built_at": BuildTime,
    })
})</code></pre>



<p class="wp-block-paragraph">本地測試：</p>



<pre class="wp-block-code"><code>go run ./cmd/main.go &amp;
curl http://localhost:8080/api/version
# {"app":"my-backend","built_at":"no-build-time","commit":"no-commit-hash","version":"no-version"}</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Step 3：在 deploy 腳本加入 <code>go build -ldflags</code></h3>



<p class="wp-block-paragraph">deploy 腳本（PowerShell 範例）裡，<code>Build-Backend</code> 函式改成：</p>



<pre class="wp-block-code"><code>function Build-Backend {
    # 1. 取得目前 commit SHA（若不在 git repo 則 fallback）
    $GIT_SHA = git rev-parse --short HEAD 2&gt;$null
    if (-not $GIT_SHA) { $GIT_SHA = "unknown" }

    $BUILD_TIME = Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ"

    Write-Host "  &#91;Backend] 編譯 Go binary (commit=$GIT_SHA)..."

    # 2. 交叉編譯 for Linux/amd64（Azure Container Apps 執行環境）
    $env:GOOS       = "linux"
    $env:GOARCH     = "amd64"
    $env:CGO_ENABLED = "0"

    Push-Location $BACKEND_DIR
    go build `
        -ldflags "-X main.CommitHash=$GIT_SHA -X main.BuildTime=$BUILD_TIME -X main.Version=$IMAGE_TAG" `
        -o "bin\backend" `
        "./cmd/main.go"
    Pop-Location

    Remove-Item Env:\GOOS, Env:\GOARCH, Env:\CGO_ENABLED -ErrorAction SilentlyContinue

    # 3. 接著才 docker build（此時 bin/backend 已是最新）
    Write-Host "  &#91;Backend] 建立 Docker Image ($IMAGE_TAG)..."
    docker build -t "$ACR_LOGIN_SERVER/backend:$IMAGE_TAG" $BACKEND_DIR
    docker push "$ACR_LOGIN_SERVER/backend:$IMAGE_TAG"
}</code></pre>



<p class="wp-block-paragraph"><strong>關鍵順序</strong>：<code>go build</code> → <code>docker build</code> → <code>docker push</code> → ACA update，缺一不可。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Step 4：部署後自動驗證版本</h3>



<p class="wp-block-paragraph">同樣在 deploy 腳本，更新 ACA 之後加入：</p>



<pre class="wp-block-code"><code># 更新 Azure Container Apps
az containerapp update `
    --name $BACKEND_APP_NAME `
    --resource-group $RESOURCE_GROUP `
    --image "$ACR_LOGIN_SERVER/backend:$IMAGE_TAG"

# 等候新 revision 啟動
Write-Host "  &#x23f3; 等待後端啟動後驗證版本..."
Start-Sleep -Seconds 30

# 驗證
try {
    $deployed = (Invoke-RestMethod "https://your-app.example.com/api/version").commit
    if ($deployed -eq $GIT_SHA) {
        Write-Host "  &#x2705; 版本驗證通過: commit=$deployed" -ForegroundColor Green
    } else {
        Write-Host "  &#x26a0;  版本不符: deployed=$deployed, local=$GIT_SHA" -ForegroundColor Yellow
        Write-Host "     可能原因：ACA revision 還沒切換完成，稍後再手動確認" -ForegroundColor Yellow
    }
} catch {
    Write-Host "  &#x26a0;  無法連線驗證 /api/version: $_" -ForegroundColor Yellow
}</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">Step 5：在前端 footer 顯示 commit SHA（選用）</h3>



<p class="wp-block-paragraph">前端也可以在 build 時嵌入 SHA，讓使用者（或開發者）能目視確認前端版本。</p>



<p class="wp-block-paragraph"><strong><code>deploy.ps1</code> Build-Frontend 函式</strong>：</p>



<pre class="wp-block-code"><code>function Build-Frontend {
    $GIT_SHA = git rev-parse --short HEAD 2&gt;$null
    if (-not $GIT_SHA) { $GIT_SHA = "unknown" }

    # 寫入 .env.production（只在 build 時使用，不會 commit 進 git）
    Set-Content -Path "$FRONTEND_DIR\.env.production" -Value @"
VITE_API_BASE_URL=https://your-app.example.com
VITE_APP_GIT_SHA=$GIT_SHA
VITE_APP_BUILD_TIME=$(Get-Date -Format "yyyy-MM-ddTHH:mm:ssZ")
"@

    Push-Location $FRONTEND_DIR
    npm run build
    Pop-Location

    docker build -t "$ACR_LOGIN_SERVER/frontend:$IMAGE_TAG" $FRONTEND_DIR
    docker push "$ACR_LOGIN_SERVER/frontend:$IMAGE_TAG"
}</code></pre>



<p class="wp-block-paragraph"><strong><code>vite-env.d.ts</code>（型別宣告）</strong>：</p>



<pre class="wp-block-code"><code>interface ImportMeta {
    readonly env: ImportMetaEnv
}

interface ImportMetaEnv {
    readonly VITE_API_BASE_URL: string
    readonly VITE_APP_GIT_SHA?: string
    readonly VITE_APP_BUILD_TIME?: string
}</code></pre>



<p class="wp-block-paragraph"><strong>React footer 元件</strong>：</p>



<pre class="wp-block-code"><code>const gitSha = import.meta.env.VITE_APP_GIT_SHA

// 在 footer 顯示
{gitSha &amp;&amp; (
    &lt;Text size="xs" c="dimmed"&gt;
        v{gitSha}
    &lt;/Text&gt;
)}</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">驗證流程總結</h2>



<p class="wp-block-paragraph">部署完成後，只需一行：</p>



<pre class="wp-block-code"><code>curl https://your-app.example.com/api/version</code></pre>



<p class="wp-block-paragraph">回傳範例：</p>



<pre class="wp-block-code"><code>{
  "app": "my-backend",
  "version": "dev-20250519153000",
  "commit": "a1b2c3d",
  "built_at": "2025-05-19T15:30:00Z"
}</code></pre>



<p class="wp-block-paragraph">再對照本地：</p>



<pre class="wp-block-code"><code>git rev-parse --short HEAD
# a1b2c3d  ← 一致 &#x2705;</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">常見問題</h2>



<h3 class="wp-block-heading">Q：<code>commit=unknown</code> 是什麼意思？</h3>



<p class="wp-block-paragraph">A：<code>git rev-parse --short HEAD</code> 找不到 git repo（例如 CI/CD 環境沒有 clone，或是在 zip 解壓的目錄執行），fallback 值為 <code>"unknown"</code>。<br>解法：確保 deploy 腳本執行時，工作目錄是 git repository 根目錄，或在 CI pipeline 裡先執行 <code>git fetch --unshallow</code>。</p>



<h3 class="wp-block-heading">Q：<code>-ldflags</code> 可以注入任何值嗎？</h3>



<p class="wp-block-paragraph">A：只能注入 <code>string</code> 型別的套件層級 <code>var</code>。格式為 <code>-X 完整套件路徑.變數名稱=值</code>。<br>例如 <code>main.go</code> 裡的 <code>var CommitHash</code> → <code>-X main.CommitHash=abc1234</code>。<br>如果變數在子套件（如 <code>internal/version</code>），則為 <code>-X github.com/your-org/your-app/internal/version.GitCommit=abc1234</code>。</p>



<h3 class="wp-block-heading">Q：Docker layer cache 怎麼辦？</h3>



<p class="wp-block-paragraph">A：每次 <code>go build</code>/<code>npm run build</code> 都會改變 <code>bin/backend</code> 或 <code>dist/</code> 的內容，讓 <code>COPY</code> 指令的 layer hash 改變，Docker 就不會沿用舊 cache。所以只要確保每次 <code>docker build</code> 前都有重新 build，cache 問題自然消除。</p>



<h3 class="wp-block-heading">Q：前後端版本不一致怎麼辦？</h3>



<p class="wp-block-paragraph">A：目前前後端各自有獨立的 SHA，若同時部署則 SHA 相同；若分開部署則可能不同。可以考慮：</p>



<ol class="wp-block-list">
<li>永遠同時部署前後端（最簡單）</li>



<li>在 <code>/api/version</code> 加入 <code>frontend_commit</code> 欄位（由前端在打 API 時傳入）</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">小結</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>問題</th><th>解法</th></tr></thead><tbody><tr><td>不知道 Azure 上跑的是哪個版本</td><td><code>/api/version</code> endpoint 回傳 commit SHA</td></tr><tr><td>Go binary 沒有重新編譯</td><td><code>go build</code> 在 <code>docker build</code> 之前執行</td></tr><tr><td>React <code>dist/</code> 沒有重新 build</td><td><code>npm run build</code> 在 <code>docker build</code> 之前執行</td></tr><tr><td>部署後不知道有沒有成功</td><td>腳本自動 curl <code>/api/version</code> 比對 SHA</td></tr><tr><td>前端版本無從確認</td><td><code>VITE_APP_GIT_SHA</code> 嵌入 build，footer 顯示</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">整套機制只需要三個地方的修改：Go 主程式加幾行、deploy 腳本加 <code>go build -ldflags</code> 和驗證邏輯、前端加一個環境變數。成本很低，但讓部署的可信度大幅提升。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">附錄：完整 deploy.ps1 骨架</h2>



<pre class="wp-block-code"><code>param(
    &#91;switch]$UpdateOnly,
    &#91;string]$Target = "All"   # Backend | Frontend | All
)

# ── 設定區 ────────────────────────────────────────────────────────────
$RESOURCE_GROUP      = "your-resource-group"
$BACKEND_APP_NAME    = "your-backend-container-app"
$FRONTEND_APP_NAME   = "your-frontend-container-app"
$ACR_LOGIN_SERVER    = "your-acr.azurecr.io"
$PUBLIC_URL          = "https://your-app.example.com"
$BACKEND_DIR         = ".\your-backend"
$FRONTEND_DIR        = ".\your-frontend"

$IMAGE_TAG = "dev-$(Get-Date -Format 'yyyyMMddHHmmss')"
$GIT_SHA   = git rev-parse --short HEAD 2&gt;$null; if (-not $GIT_SHA) { $GIT_SHA = "unknown" }

# ── 函式區 ────────────────────────────────────────────────────────────
function Build-Backend {
    Write-Host "--- 重建並部署 Backend ---"
    Write-Host "  &#91;Backend] 編譯 Go binary (commit=$GIT_SHA)..."

    $env:GOOS = "linux"; $env:GOARCH = "amd64"; $env:CGO_ENABLED = "0"
    Push-Location $BACKEND_DIR
    go build -ldflags "-X main.CommitHash=$GIT_SHA -X main.Version=$IMAGE_TAG" `
             -o "bin\backend" "./cmd/main.go"
    Pop-Location
    Remove-Item Env:\GOOS, Env:\GOARCH, Env:\CGO_ENABLED -ErrorAction SilentlyContinue

    Write-Host "  &#91;Backend] 建立 Docker Image..."
    docker build -t "$ACR_LOGIN_SERVER/backend:$IMAGE_TAG" $BACKEND_DIR
    docker push  "$ACR_LOGIN_SERVER/backend:$IMAGE_TAG"

    Write-Host "  &#91;Backend] 更新 Container App..."
    az containerapp update --name $BACKEND_APP_NAME `
                           --resource-group $RESOURCE_GROUP `
                           --image "$ACR_LOGIN_SERVER/backend:$IMAGE_TAG"

    # 驗證
    Start-Sleep -Seconds 30
    try {
        $deployed = (Invoke-RestMethod "$PUBLIC_URL/api/version").commit
        if ($deployed -eq $GIT_SHA) {
            Write-Host "  &#x2705; 版本驗證通過: commit=$deployed" -ForegroundColor Green
        } else {
            Write-Host "  &#x26a0;  版本不符: deployed=$deployed, local=$GIT_SHA" -ForegroundColor Yellow
        }
    } catch {
        Write-Host "  &#x26a0;  無法連線驗證: $_" -ForegroundColor Yellow
    }
}

function Build-Frontend {
    Write-Host "--- 重建並部署 Frontend ---"

    Set-Content "$FRONTEND_DIR\.env.production" @"
VITE_API_BASE_URL=$PUBLIC_URL
VITE_APP_GIT_SHA=$GIT_SHA
"@

    Push-Location $FRONTEND_DIR
    npm run build
    Pop-Location

    docker build -t "$ACR_LOGIN_SERVER/frontend:$IMAGE_TAG" $FRONTEND_DIR
    docker push  "$ACR_LOGIN_SERVER/frontend:$IMAGE_TAG"

    az containerapp update --name $FRONTEND_APP_NAME `
                           --resource-group $RESOURCE_GROUP `
                           --image "$ACR_LOGIN_SERVER/frontend:$IMAGE_TAG"

    Write-Host "  &#x2705; Frontend 部署完成" -ForegroundColor Green
}

# ── 執行 ─────────────────────────────────────────────────────────────
az acr login --name ($ACR_LOGIN_SERVER -replace "\.azurecr\.io","")

if ($Target -in @("Backend","All")) { Build-Backend }
if ($Target -in @("Frontend","All")) { Build-Frontend }</code></pre>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/05/azure-container-apps-ver-check/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Azure Container Apps 多網域部署指南</title>
		<link>https://stackoverflow.max-everyday.com/2026/05/azure-container-apps-multi-domain/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/05/azure-container-apps-multi-domain/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Tue, 19 May 2026 03:27:21 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[azure]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8443</guid>

					<description><![CDATA[如何在同一個 Azure Container A...]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">如何在同一個 Azure Container Apps Environment（CAE）中，<br>透過 Application Gateway（AGW）將<strong>不同網域</strong>路由到<strong>不同的 Container App</strong>，<br>達到單一基礎設施服務多個應用程式的效果。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">架構圖</h2>



<pre class="wp-block-code"><code>                        ┌─────────────────────────────────────────────┐
外部使用者                │  Virtual Network (vnet-ag-example-001)        │
  │                     │  ┌──────────────────────────────────────┐   │
  │ HTTPS               │  │  Application Gateway (agw-example)   │   │
  ▼                     │  │                                      │   │
&#91;app-a.example.com]─────┼──►  Listener: app-a.example.com        │   │
&#91;app-b.example.com]─────┼──►  Listener: app-b.example.com        │   │
                        │  └───────────┬──────────────┬───────────┘   │
                        └─────────────┼──────────────┼───────────────┘
                                      │ VNet Peering │
                        ┌─────────────┼──────────────┼───────────────┐
                        │  Virtual Network (vnet-example-001)         │
                        │             │              │                │
                        │  ┌──────────▼──────────────▼───────────┐   │
                        │  │  Container Apps Environment (CAE)   │   │
                        │  │  (Internal, Consumption)            │   │
                        │  │                                      │   │
                        │  │  ca-app-a  (port 80,  nginx)        │   │
                        │  │  ca-app-b  (port 8080, api)         │   │
                        │  └─────────────────────────────────────┘   │
                        │                                             │
                        └─────────────────────────────────────────────┘</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">前置條件</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>資源</th><th>說明</th></tr></thead><tbody><tr><td>Container Apps Environment</td><td>建立為 <strong>Internal</strong>（Workload Profile: Consumption）</td></tr><tr><td>VNet for CAE</td><td>例如 <code>vnet-example-001</code>，CAE 使用其中一個子網路</td></tr><tr><td>VNet for AGW</td><td>例如 <code>vnet-ag-example-001</code>，AGW 使用其中一個子網路</td></tr><tr><td>VNet Peering</td><td>上述兩個 VNet 互相 Peered</td></tr><tr><td>Private DNS Zone</td><td>由 CAE 自動建立，需手動連結至 AGW 所在的 VNet</td></tr><tr><td>SSL 憑證</td><td>每個自訂網域需有對應的憑證（PFX 格式上傳至 AGW）</td></tr></tbody></table></figure>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">一、Private DNS Zone 設定</h2>



<p class="wp-block-paragraph">CAE 建立後，Azure 會自動在同一資源群組建立一個 Private DNS Zone，<br>格式為：<code>&lt;隨機字串&gt;.&lt;region&gt;.azurecontainerapps.io</code><br>例如：<code>abcd1234efgh.japaneast.azurecontainerapps.io</code></p>



<p class="wp-block-paragraph"><strong>此 DNS Zone 需與 AGW 所在的 VNet 連結，AGW 才能解析 CA 的 FQDN。</strong></p>



<pre class="wp-block-code"><code>az network private-dns link vnet create \
  --resource-group &lt;cae-resource-group&gt; \
  --zone-name "abcd1234efgh.japaneast.azurecontainerapps.io" \
  --name "link-to-agw-vnet" \
  --virtual-network &lt;agw-vnet-id&gt; \
  --registration-enabled false</code></pre>



<h3 class="wp-block-heading">Wildcard DNS 記錄</h3>



<p class="wp-block-paragraph">Private DNS Zone 中有一筆 Wildcard A 記錄：</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>名稱</th><th>類型</th><th>值</th></tr></thead><tbody><tr><td><code>*</code></td><td>A</td><td><code>&lt;CAE 內部 Load Balancer IP&gt;</code></td></tr></tbody></table></figure>



<p class="wp-block-paragraph">此 Wildcard 只能匹配<strong>單層</strong>子網域（如 <code>ca-app-a.abcd1234efgh...</code>），<br><strong>無法</strong>匹配 <code>ca-app-a.internal.abcd1234efgh...</code>（雙層）。</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> <strong>注意</strong>：若使用 <code>--ingress internal</code> 建立 CA，其 FQDN 會包含 <code>.internal.</code>，<br>不會被 Wildcard 記錄匹配，AGW 將無法解析。<br>請一律使用 <code>--ingress external</code>（在 Internal CAE 內，external 仍為 VNet 私有，並非真正對外）。</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">二、建立 Container App</h2>



<p class="wp-block-paragraph">每個應用程式建立時指定 <code>--ingress external</code>，對應的 FQDN 格式為：<br><code>&lt;app-name&gt;.&lt;env-domain&gt;</code></p>



<pre class="wp-block-code"><code># 建立 App A（前端，nginx on port 80）
az containerapp create \
  --name ca-app-a \
  --resource-group &lt;resource-group&gt; \
  --environment &lt;cae-name&gt; \
  --image &lt;registry&gt;.azurecr.io/app-a:latest \
  --target-port 80 \
  --ingress external \
  --registry-server &lt;registry&gt;.azurecr.io

# 建立 App B（後端 API，on port 8080）
az containerapp create \
  --name ca-app-b \
  --resource-group &lt;resource-group&gt; \
  --environment &lt;cae-name&gt; \
  --image &lt;registry&gt;.azurecr.io/app-b:latest \
  --target-port 8080 \
  --ingress external \
  --registry-server &lt;registry&gt;.azurecr.io</code></pre>



<p class="wp-block-paragraph">建立後，各 CA 的 FQDN 例如：</p>



<ul class="wp-block-list">
<li><code>ca-app-a.abcd1234efgh.japaneast.azurecontainerapps.io</code></li>



<li><code>ca-app-b.abcd1234efgh.japaneast.azurecontainerapps.io</code></li>
</ul>



<h3 class="wp-block-heading">允許 HTTP（不強制重新導向 HTTPS）</h3>



<p class="wp-block-paragraph">ACA 預設會將 HTTP 請求 301 重新導向至 HTTPS。<br>若 AGW 以 HTTP 協定連至 CA（通常如此，因為 TLS 已在 AGW 終止），<br>需對每個 CA 啟用 <code>--allow-insecure</code>：</p>



<pre class="wp-block-code"><code>az containerapp ingress update \
  --name ca-app-a \
  --resource-group &lt;resource-group&gt; \
  --allow-insecure</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">三、Application Gateway 設定</h2>



<p class="wp-block-paragraph">同一個 AGW 可以透過<strong>多組 Listener + Rule + Backend Pool</strong> 路由不同網域。</p>



<h3 class="wp-block-heading">3-1 Backend Pool</h3>



<p class="wp-block-paragraph">每個應用程式建立一個 Backend Pool，目標為 CA 的 FQDN：</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Pool 名稱</th><th>FQDN</th></tr></thead><tbody><tr><td><code>pool-app-a</code></td><td><code>ca-app-a.abcd1234efgh.japaneast.azurecontainerapps.io</code></td></tr><tr><td><code>pool-app-b</code></td><td><code>ca-app-b.abcd1234efgh.japaneast.azurecontainerapps.io</code></td></tr></tbody></table></figure>



<pre class="wp-block-code"><code>az network application-gateway address-pool create \
  --gateway-name &lt;agw-name&gt; \
  --resource-group &lt;agw-resource-group&gt; \
  --name pool-app-a \
  --servers ca-app-a.abcd1234efgh.japaneast.azurecontainerapps.io</code></pre>



<h3 class="wp-block-heading">3-2 Backend HTTP Settings</h3>



<p class="wp-block-paragraph">指定協定為 HTTP（因為 TLS 在 AGW 終止）、Port 80，<br>並<strong>固定 Host Header 為 CA 的 FQDN</strong>，使 ACA Ingress 能正確路由：</p>



<pre class="wp-block-code"><code>az network application-gateway http-settings create \
  --gateway-name &lt;agw-name&gt; \
  --resource-group &lt;agw-resource-group&gt; \
  --name set-app-a \
  --protocol Http \
  --port 80 \
  --host-name "ca-app-a.abcd1234efgh.japaneast.azurecontainerapps.io"</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><code>--host-name</code> 設定會讓 AGW 傳送正確的 <code>Host</code> Header 給 ACA，<br>若不設定，ACA Ingress 收到的 Host 可能是原始網域（如 <code>app-a.example.com</code>），<br>導致路由失敗（HTTP 404）。</p>
</blockquote>



<h3 class="wp-block-heading">3-3 HTTPS Listener</h3>



<p class="wp-block-paragraph">每個自訂網域需要一個 Listener：</p>



<pre class="wp-block-code"><code># 先上傳 SSL 憑證
az network application-gateway ssl-cert create \
  --gateway-name &lt;agw-name&gt; \
  --resource-group &lt;agw-resource-group&gt; \
  --name cert-app-a \
  --cert-file app-a.pfx \
  --cert-password &lt;password&gt;

# 建立 Listener
az network application-gateway http-listener create \
  --gateway-name &lt;agw-name&gt; \
  --resource-group &lt;agw-resource-group&gt; \
  --name lsn-app-a-https \
  --frontend-port &lt;https-port-name&gt; \
  --frontend-ip &lt;frontend-ip-config-name&gt; \
  --host-name "app-a.example.com" \
  --ssl-cert cert-app-a</code></pre>



<h3 class="wp-block-heading">3-4 Routing Rule</h3>



<p class="wp-block-paragraph">將 Listener 連接至 Backend Pool + HTTP Settings：</p>



<pre class="wp-block-code"><code>az network application-gateway rule create \
  --gateway-name &lt;agw-name&gt; \
  --resource-group &lt;agw-resource-group&gt; \
  --name rule-app-a \
  --http-listener lsn-app-a-https \
  --address-pool pool-app-a \
  --http-settings set-app-a \
  --rule-type Basic \
  --priority 200</code></pre>



<p class="wp-block-paragraph">重複 3-1 至 3-4 步驟，為 <code>app-b.example.com</code> 建立對應的資源。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">四、新增應用程式到現有環境</h2>



<p class="wp-block-paragraph">只需重複以下步驟，<strong>無需修改 DNS Zone 或 CAE 設定</strong>：</p>



<ol class="wp-block-list">
<li>建立 Container App（<code>--ingress external</code>）</li>



<li>啟用 <code>--allow-insecure</code></li>



<li>AGW 新增 Backend Pool（目標為新 CA 的 FQDN）</li>



<li>AGW 新增 HTTP Settings（Host Header 設為新 CA 的 FQDN）</li>



<li>AGW 新增 Listener（綁定新網域的 SSL 憑證）</li>



<li>AGW 新增 Routing Rule（連接上述三者）</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">五、CA 間互相呼叫</h2>



<p class="wp-block-paragraph">在同一個 Internal CAE 中，Container App 可透過 FQDN 直接呼叫彼此，<br>不需經過 AGW：</p>



<pre class="wp-block-code"><code>http:&#47;&#47;ca-app-b.abcd1234efgh.japaneast.azurecontainerapps.io/api/endpoint</code></pre>



<p class="wp-block-paragraph">例如前端 nginx 的 <code>proxy_pass</code> 設定：</p>



<pre class="wp-block-code"><code>location /api/ {
    proxy_pass http://ca-app-b.abcd1234efgh.japaneast.azurecontainerapps.io/api/;
}</code></pre>



<p class="wp-block-paragraph">建議將後端 FQDN 透過環境變數注入（例如 <code>BACKEND_URL</code>），<br>搭配 nginx 的 <code>envsubst</code> 機制於容器啟動時替換：</p>



<pre class="wp-block-code"><code># nginx.conf.template
location /api/ {
    proxy_pass ${BACKEND_URL}/api/;
}</code></pre>



<pre class="wp-block-code"><code># Dockerfile：放在 /etc/nginx/templates/ 會在啟動時自動替換
COPY .deploy/nginx.conf.template /etc/nginx/templates/default.conf.template</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">六、常見問題</h2>



<h3 class="wp-block-heading">AGW Backend Health 顯示 Unknown 或 502</h3>



<ol class="wp-block-list">
<li><strong>DNS 無法解析 CA FQDN</strong>：確認 Private DNS Zone 已連結至 AGW 所在的 VNet。</li>



<li><strong>使用了 <code>.internal.</code> FQDN</strong>：CA 建立時使用了 <code>--ingress internal</code>，改為 <code>--ingress external</code>。</li>



<li><strong>HTTP 重新導向</strong>：CA 收到 HTTP 請求回傳 301，對該 CA 執行 <code>az containerapp ingress update --allow-insecure</code>。</li>



<li><strong>Host Header 不符</strong>：AGW 的 HTTP Settings 未設定 <code>--host-name</code>，導致 ACA Ingress 無法識別目標應用程式。</li>
</ol>



<h3 class="wp-block-heading">CA 啟動失敗：<code>open .: no such file or directory</code></h3>



<p class="wp-block-paragraph">應用程式嘗試讀取相對路徑的檔案（如資料庫 Migration 檔），<br>但工作目錄與預期不符。<br>解法：透過環境變數指定絕對路徑，例如：</p>



<pre class="wp-block-code"><code>az containerapp update \
  --name ca-app-b \
  --resource-group &lt;resource-group&gt; \
  --set-env-vars "MIGRATION_SOURCE=file:///app/migrations"</code></pre>



<h3 class="wp-block-heading">CA 中的環境變數包含 <code>&amp;</code> 字元</h3>



<p class="wp-block-paragraph">在 Windows 環境下使用 <code>az</code> CLI（<code>.cmd</code> 包裝器）時，<code>&amp;</code> 會被 cmd.exe 解析為命令分隔符，<br>導致參數被截斷。解法：改用 <code>az rest --method PATCH</code> 搭配 JSON 檔案傳遞：</p>



<pre class="wp-block-code"><code>{
  "properties": {
    "template": {
      "containers": &#91;{
        "name": "ca-app-b",
        "env": &#91;
          { "name": "DATABASE_URL", "value": "sqlserver://host?db=mydb&amp;param=value" }
        ]
      }]
    }
  }
}</code></pre>



<pre class="wp-block-code"><code>az rest --method PATCH \
  --url "https://management.azure.com/subscriptions/&lt;sub&gt;/resourceGroups/&lt;rg&gt;/providers/Microsoft.App/containerApps/&lt;name&gt;?api-version=2024-03-01" \
  --body @patch.json</code></pre>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/05/azure-container-apps-multi-domain/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>解決 Azure App Service 跨區域連線與 VNet 整合挑戰</title>
		<link>https://stackoverflow.max-everyday.com/2026/05/azure-app-service-crose-location/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/05/azure-app-service-crose-location/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Tue, 19 May 2026 01:52:17 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[azure]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8440</guid>

					<description><![CDATA[在雲端架構中，我們常會遇到服務分散在不同區域的情...]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">在雲端架構中，我們常會遇到服務分散在不同區域的情況。例如，你的 App Service 部署在 <strong>Taiwan North</strong> ，但主要的測試環境與虛擬網路（ VNet ）卻位在 <strong>Japan East</strong> 。當你發現 App Service 無法連線至合作廠商 <strong>宏達科技（ GrandTech ）</strong> 的內部網路時，通常不只是程式碼的問題，而是底層網路架構需要調整。</p>



<h3 class="wp-block-heading">問題現象：連線超時與網路阻斷</h3>



<p class="wp-block-paragraph">當 App Service 發出請求卻一直收到 <strong>TLS timeout</strong> 或 <strong>http_status=0</strong> 時，這代表請求根本沒有成功建立連線。這通常有兩個主要原因：</p>



<ol start="1" class="wp-block-list">
<li><strong>IP 白名單限制</strong>：宏達科技的伺服器可能設有防火牆，只允許特定的 IP 區段進入，而 Azure App Service 的預設公用輸出 IP 並不在允許清單內。</li>



<li><strong>網路區域限制</strong>：位在 Japan East 的測試用虛擬機器（ VM ）可以成功連通，是因為該區域的網路已經與宏達科技建立了信任通道（ 或者是固定的白名單 IP ），但 Taiwan North 的流量則是走公用網路出去，因此被對方防火牆攔截。</li>
</ol>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">關鍵解決策略：虛擬網路整合</h3>



<p class="wp-block-paragraph">要讓 App Service 像虛擬機器一樣擁有受控的網路路徑，必須執行 <strong>VNet Integration</strong> （ 虛擬網路整合 ）。但當 App Service 與目標 VNet 跨區域時，我們會遇到一些硬性限制。</p>



<h3 class="wp-block-heading">實戰步驟一：檢查現有網路狀態</h3>



<p class="wp-block-paragraph">首先，我們需要確認 Taiwan North 當地的網路環境。你可以使用 Azure CLI 檢查該區域的 VNet 清單：</p>



<p class="wp-block-paragraph">az network vnet list &#8211;query &#8220;[?location==&#8217;northtaiwan&#8217;].{name:name,rg:resourceGroup}&#8221; -o table</p>



<p class="wp-block-paragraph">在我們的案例中，發現 Taiwan North 只有一個名為 <strong>vnet-proxy-agent</strong> 的虛擬網路。</p>



<h3 class="wp-block-heading">實戰步驟二：處理子網域委派問題</h3>



<p class="wp-block-paragraph">若要讓 App Service 加入 VNet ，該子網域（ Subnet ）必須委派給 <strong>Microsoft.Web/serverFarms</strong> 。</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">注意：如果該子網域已經有其他虛擬機器在使用，你無法直接更新委派設定。</p>
</blockquote>



<p class="wp-block-paragraph">這時正確的做法是在 <strong>vnet-proxy-agent</strong> 中，為 App Service 建立一個專屬且乾淨的子網域。</p>



<h3 class="wp-block-heading">實戰步驟三：跨區域網路串接（ Peering ）</h3>



<p class="wp-block-paragraph">由於宏達科技只認可 Japan East 網路出口的 IP ，我們必須確認 Taiwan North 的 VNet 是否已經與 Japan East 的 Hub VNet 建立 <strong>Peering</strong> （ 虛擬網路對等互連 ）。</p>



<p class="wp-block-paragraph">當 Peering 建立後， Taiwan North 的流量就能透過 Azure 骨幹網路導向 Japan East ，再經由那邊的白名單管道存取宏達科技的伺服器。</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">最終檢查清單</h3>



<ol start="1" class="wp-block-list">
<li><strong>新增專屬子網域</strong>：在 Taiwan North 的 VNet 中切分出一塊新的 IP 區段。</li>



<li><strong>設定委派</strong>：將該子網域委派給 Web App 使用。</li>



<li><strong>啟用路由設定</strong>：在 App Service 的環境變數中，務必設定 <strong>WEBSITE_VNET_ROUTE_ALL=1</strong> ，以確保所有對外流量都強制經過 VNet ，而不是走預設的 Azure 公用出口。</li>



<li><strong>重新部署</strong>：更改環境變數與網路整合設定後，務必重新啟動或部署應用程式，確保新的網路路徑生效。</li>
</ol>



<h3 class="wp-block-heading">結語</h3>



<p class="wp-block-paragraph">解決雲端連線問題時， <strong>視覺化你的網路路徑</strong> 非常重要。當公用網路不通時，善用 Azure 的區域性 VNet 與跨區域對等互連技術，能有效解決 IP 阻擋與連線超時的困擾。如果你也遇到類似的跨區域連線問題，不妨檢查一下你的 VNet 委派與路由路徑！</p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/05/azure-app-service-crose-location/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
