

<?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>Max的程式語言筆記</title>
	<atom:link href="https://stackoverflow.max-everyday.com/feed/" rel="self" type="application/rss+xml" />
	<link>https://stackoverflow.max-everyday.com</link>
	<description>我要當一個豬頭，快樂過每一天</description>
	<lastBuildDate>Thu, 15 Oct 2026 20:04:48 +0000</lastBuildDate>
	<language>zh-TW</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.3</generator>

<image>
	<url>https://stackoverflow.max-everyday.com/wp-content/uploads/2017/02/max-stackoverflow-256.png</url>
	<title>Max的程式語言筆記</title>
	<link>https://stackoverflow.max-everyday.com</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>解決 WordPress 深色模式切換時超連結「白轉黑 0.5 秒」殘影閃爍問題</title>
		<link>https://stackoverflow.max-everyday.com/2026/10/wordpress-bgcolor-from-white-to-black-blink/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/10/wordpress-bgcolor-from-white-to-black-blink/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Thu, 15 Oct 2026 19:58:10 +0000</pubDate>
				<category><![CDATA[css筆記]]></category>
		<category><![CDATA[WordPress筆記]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8800</guid>

					<description><![CDATA[在為 WordPress 主題自訂深色模式（Da...]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">在為 WordPress 主題自訂深色模式（Dark Mode / Dark Theme）切換功能時，許多開發者常會遇到一個相當破壞使用體驗的視覺 Bug：</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>現象描述：</strong><br>點擊切換為深色模式（或由深色轉淺色）的瞬間，頁面上的<strong>超連結（<code>&lt;a&gt;</code>）或文字區塊背景</strong>會先短暫閃出一塊白色（<code>#ffffff</code>），接著在約 <strong>0.5 秒</strong>的時間內才緩慢漸變過渡為黑色（<code>#000000</code> / <code>#16181d</code>）。</p>
</blockquote>



<p class="wp-block-paragraph">這並非 JavaScript 執行延遲，而是 CSS 過渡動畫（Transitions）與瀏覽器補間運算相互作用產生的典型副作用。本文將完整記錄該問題的<strong>根本成因</strong>與<strong>最佳實踐解法</strong>。</p>



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



<h2 class="wp-block-heading">一、問題根本原因分析</h2>



<p class="wp-block-paragraph">這個問題主要由兩個 CSS 特性疊加造成：</p>



<h3 class="wp-block-heading">1. 原生主題的 <code>transition: all 0.5s ease;</code> 廣泛繼承</h3>



<p class="wp-block-paragraph">在許多 WordPress 主題（包括 Gutenshop）的樣式表（<code>style.css</code>）中，為了讓按鈕與部分互動元素具備平滑動畫，常常撰寫了如下規則：</p>



<pre class="wp-block-code"><code>transition: all 0.5s ease;</code></pre>



<p class="wp-block-paragraph">當使用 JavaScript 動態在 <code>&lt;body&gt;</code> 或 <code>&lt;html&gt;</code> 上新增/切換 <code>.theme-dark</code> 類別時，瀏覽器會監測到樣式變更。由於設定了 <code>transition: all</code>，<strong>頁面上所有繼承或套用了該規則的屬性（包含 <code>background-color</code>、<code>color</code>、<code>border-color</code>）都會進入 0.5 秒的漸變過渡狀態</strong>。</p>



<h3 class="wp-block-heading">2. 顏色插值運算（Color Interpolation）與透明背景缺失</h3>



<ul class="wp-block-list">
<li>預設情況下，常規文字超連結 <code>&lt;a></code> 的 <code>background-color</code> 通常是 <code>transparent</code>（透明）或繼承父容器。</li>



<li>當深淺模式切換時，如果某些元件或祖先元素設定了背景色變換，瀏覽器在計算 <code>transparent</code>（實質為 <code>rgba(0,0,0,0)</code> 或 <code>rgba(255,255,255,0)</code>）至目標色（如 <code>#16181d</code>）的中間過渡幀時，容易將未明定背景的元素以白色底（<code>#fff</code>）作為基底進行過渡插值。</li>



<li>結果就形成了：<strong>超連結背景突然變白，並耗時 0.5 秒慢慢過渡到黑色</strong>的突兀閃爍。</li>
</ul>



<h3 class="wp-block-heading">3. 首次造訪無 Cookie 時，未即時寫入使用者系統偏好</h3>



<ul class="wp-block-list">
<li>若使用者尚未手動點擊切換過，瀏覽器中就沒有 <code>gutenshop_theme</code> 的 Cookie。</li>



<li>每次重新整理或跳轉頁面時，若僅依賴延遲載入的腳本偵測 <code>prefers-color-scheme: dark</code>，頁面在 HTML 解析初期無法從 Cookie 取得狀態，導致從預設的淺色（白）重新被腳本動態判定改為深色（黑），觸發二次切換閃爍。</li>



<li><strong>最佳解法：</strong> 在初次載入（Page Load）偵測到無 Cookie 時，<strong>立即將使用者的 System Theme 寫入 Cookie</strong>，並在 <code>&lt;head></code> 的 Anti-flicker 行內腳本同步寫入，確保下一個畫面與未來所有子頁面在 DOM 渲染前就能以 Cookie 命中深色模式，徹底杜絕閃爍。</li>
</ul>



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



<h2 class="wp-block-heading">二、解決方案</h2>



<p class="wp-block-paragraph">要徹底杜絕這個現象，最佳做法是「<strong>雙管齊下</strong>」：</p>



<ol class="wp-block-list">
<li><strong>切換當下全域靜音過渡（Disable Transitions during Toggle）</strong>：切換主題時暫時鎖死所有 transition，讓深淺色瞬間套用，不觸發任何補間動畫。</li>



<li><strong>約束超連結的 transition 範圍</strong>：明確鎖定 <code>&lt;a></code> 的背景為透明，並限制僅有 <code>color</code>（文字顏色）允許 hover 漸變，禁止背景參與 transition。</li>
</ol>



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



<h3 class="wp-block-heading">步驟 1：在 CSS 中加入停用過渡與連結約束規則</h3>



<p class="wp-block-paragraph">在你的獨立深色模式樣式檔（如 <code>css/dark-theme.css</code>）加入以下規則：</p>



<pre class="wp-block-code"><code>/* ==========================================================================
   1. 暫停過渡工具類：切換主題瞬間徹底消除 0.5 秒的顏色補間閃爍
   ========================================================================== */
.disable-transitions,
.disable-transitions *,
.disable-transitions *::before,
.disable-transitions *::after {
  -webkit-transition: none !important;
  -moz-transition: none !important;
  -o-transition: none !important;
  -ms-transition: none !important;
  transition: none !important;
}

/* ==========================================================================
   2. 超連結背景與過渡約束：
      明確限制常規連結背景為透明，且過渡僅限文字顏色 (color)，防止背景補間
   ========================================================================== */
a:not(.button):not(.added_to_cart):not(.theme-switch-link) {
  background-color: transparent;
  transition-property: color;
  transition-duration: 0.2s;
}

/* 深色模式下連結文字配色 */
body.theme-dark a {
  color: #60a5fa;
}

body.theme-dark a:hover,
body.theme-dark a:focus {
  color: #93c5fd;
}</code></pre>



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



<h3 class="wp-block-heading">步驟 2：在切換 JavaScript 中包裝過渡阻斷邏輯</h3>



<p class="wp-block-paragraph">在處理主題切換的 JavaScript（如 <code>js/theme-switch.js</code>）的 <code>applyTheme</code> 函數中：</p>



<ul class="wp-block-list">
<li>切換前先在根節點 <code>&lt;html></code> 加上 <code>.disable-transitions</code>。</li>



<li>透過存取 <code>window.getComputedStyle().opacity</code> 強制觸發瀏覽器重繪（Force Reflow），保證類別立即生效且不會被瀏覽器批次合併渲染。</li>



<li>完成類別切換後，使用 <code>requestAnimationFrame</code> 在下一個畫面幀將 <code>.disable-transitions</code> 移除，確保按鈕後續正常的滑鼠 hover 動畫不受影響。</li>
</ul>



<pre class="wp-block-code"><code>/**
 * 套用深淺主題類別
 * @param {string} theme 'dark' 或 'light'
 */
function applyTheme(theme) {
  var isDark = (theme === 'dark');

  // 1. 立即停止當前所有 CSS 過渡動畫，防止 0.5s 白黑殘影閃爍
  document.documentElement.classList.add('disable-transitions');

  // 2. 切換深淺模式類別
  document.body.classList.toggle('theme-dark', isDark);
  document.body.classList.toggle('theme-light', !isDark);
  document.documentElement.classList.toggle('theme-dark', isDark);
  document.documentElement.classList.toggle('theme-light', !isDark);

  // 3. 強制觸發重繪 (Reflow)，確保樣式變更在 transition 被關閉的狀態下生效
  window.getComputedStyle(document.documentElement).opacity;

  // 4. 下一個渲染週期恢復原本的 CSS 過渡效果（按鈕 hover 等仍維持流暢動畫）
  requestAnimationFrame(function () {
    document.documentElement.classList.remove('disable-transitions');
  });

  // 5. 同步切換按鈕的 aria 屬性與提示文字
  var links = document.querySelectorAll('.theme-switch-link');
  links.forEach(function (el) {
    el.title = isDark ? '切換至淺色模式' : '切換至深色模式';
    el.setAttribute('aria-pressed', isDark ? 'true' : 'false');
  });
}

/**
 * 頁面初始化：當無 Cookie 時，立即將使用者的系統主題 (OS Theme) 寫入 Cookie
 */
function init() {
  var saved = getCookie(COOKIE_NAME);
  var currentTheme;

  if (saved === 'dark' || saved === 'light') {
    currentTheme = saved;
  } else {
    // 首次造訪無 cookie：抓取系統主題並立即寫入 cookie，避免二次載入或跳轉時重複判斷而閃爍
    currentTheme = getSystemTheme();
    setCookie(COOKIE_NAME, currentTheme, COOKIE_DAYS);
  }

  var isDark = (currentTheme === 'dark');
  applyTheme(currentTheme);
  injectDesktopToggle(isDark);
  injectMobileToggle(isDark);
  document.addEventListener('click', handleClick);
  watchOSTheme();
}</code></pre>



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



<h3 class="wp-block-heading">步驟 3：在 Head 注入極速防閃爍腳本並更新版本號</h3>



<p class="wp-block-paragraph">在 <code>functions.php</code> 的 <code>wp_enqueue_scripts</code> 中：</p>



<ol class="wp-block-list">
<li>注入極速 Inline 腳本：若初次無 Cookie，亦立即將系統主題寫入 Cookie 並先加上 <code>theme-dark</code> 類別，在 HTML 剛載入時就完成判定。</li>



<li>推進樣式表與腳本版本號（例如 <code>1.0.1</code> / <code>1.0.3</code>），避免瀏覽器快取舊檔案：</li>
</ol>



<pre class="wp-block-code"><code>// functions.php
wp_register_style( 
    'gutenshop-dark-theme', 
    get_template_directory_uri() . '/css/dark-theme.css', 
    array( 'gutenshop-style' ), 
    '1.0.1' 
);
wp_enqueue_style( 'gutenshop-dark-theme' );

// 防閃爍腳本：搶先注入 &lt;html&gt;，若無 cookie 則同步寫入系統主題 cookie
$anti_flicker_js = "(function(){try{var m=document.cookie.match(/(^|;\\s*)gutenshop_theme=(&#91;^;]+)/);var t=m?decodeURIComponent(m&#91;2]):null;var d=(window.matchMedia&amp;&amp;window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';var theme=t||d;if(!t){var exp=new Date();exp.setTime(exp.getTime()+(365*24*60*60*1000));document.cookie='gutenshop_theme='+encodeURIComponent(d)+'; expires='+exp.toUTCString()+'; path=/; SameSite=Lax';}if(theme==='dark'){document.documentElement.classList.add('theme-dark');}}catch(e){}})();";
wp_add_inline_script( 'gutenshop-navigation', $anti_flicker_js, 'before' );

wp_register_script( 
    'gutenshop-theme-switch', 
    get_template_directory_uri() . '/js/theme-switch.js', 
    array( 'gutenshop-custom-js-jquery' ), 
    '1.0.3', 
    true 
);
wp_enqueue_script( 'gutenshop-theme-switch' );</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>點擊深淺模式切換按鈕</strong>：背景與所有文字立刻無縫切換，<strong>完全不再出現 0.5 秒的白色閃爍與變色殘影</strong>。</li>



<li><strong>一般互動不受影響</strong>：一般的按鈕 hover、下拉選單滑動等原有動畫依然正常流暢運作。</li>



<li><strong>效能優化</strong>：利用強制 Reflow 配合 <code>requestAnimationFrame</code>，不會造成頁面重載或多餘運算開銷。</li>
</ol>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/10/wordpress-bgcolor-from-white-to-black-blink/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>如何為 WordPress 主題無痛加入深色模式？Git Patch 與高效率 Prompt 實戰</title>
		<link>https://stackoverflow.max-everyday.com/2026/10/wordpress-dark-mode-git-patch-prompt/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/10/wordpress-dark-mode-git-patch-prompt/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Thu, 15 Oct 2026 16:19:35 +0000</pubDate>
				<category><![CDATA[WordPress筆記]]></category>
		<category><![CDATA[AI]]></category>
		<category><![CDATA[css]]></category>
		<category><![CDATA[wordpress]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8796</guid>

					<description><![CDATA[在現代網頁設計中，深色模式（Dark Mode ...]]></description>
										<content:encoded><![CDATA[
<figure class="wp-block-image size-large"><img fetchpriority="high" decoding="async" width="1024" height="572" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_4204396195813658961_clean-1024x572.jpg?v=1792081142" alt="" class="wp-image-8798" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_4204396195813658961_clean-1024x572.jpg?v=1792081142 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_4204396195813658961_clean-767x428.jpg?v=1792081142 767w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_4204396195813658961_clean-600x335.jpg?v=1792081142 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/10/watermarked_img_4204396195813658961_clean.jpg?v=1792081142 1376w" sizes="(max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">在現代網頁設計中，深色模式（Dark Mode / Dark Theme）已經是用戶體驗（UX）的基本標配。除了降低在暗處閱讀時的眼部疲勞，也能延長 OLED 螢幕的電池續航力。</p>



<p class="wp-block-paragraph">本篇技術筆記以開源主題 <strong>Guntenbee</strong> 為例，完整記錄如何架構一個具備「<strong>系統主題偏好自動偵測</strong> + <strong>Cookie 記憶</strong> + <strong>切換按鈕</strong> + <strong>防閃爍 (Anti-flicker)</strong>」的深色模式方案。同時針對日後「<strong>原作者主題升級</strong>」時如何以零成本（Git Patch / Child Theme）或極低 Token 的 AI 提示詞無痛重新套用進行深度解析。</p>



<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>使用者優先權階層</strong>：
<ul class="wp-block-list">
<li>若使用者曾在站上點擊切換過，以 <strong>Cookie 儲存的偏好</strong> 為最優先。</li>



<li>若為首次造訪或未設定 Cookie，自動偵測使用者的<strong>系統外觀模式</strong>（<code>prefers-color-scheme: dark</code>）。</li>
</ul>
</li>



<li><strong>避免白光閃爍（Anti-flicker / FOUC）</strong>：
<ul class="wp-block-list">
<li>瀏覽器解析 HTML 時，如果等到完整腳本下載並在 DOMContentLoaded 才執行加 class，畫面上會出現短暫的白光閃爍。因此需在 <code>&lt;head></code> 注入極輕量的行內（Inline）腳本提早設定 class。</li>
</ul>
</li>



<li><strong>直覺易用的開關位置</strong>：
<ul class="wp-block-list">
<li>將切換開關置入 Header 頂部導覽列（<code>&lt;div class="head-mast-inner"></code>），確保各裝置與版型都能一致顯示。</li>
</ul>
</li>
</ol>



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



<h2 class="wp-block-heading">二、功能實作步驟</h2>



<h3 class="wp-block-heading">1. 樣式實作：獨立樣式表 (<code>css/dark-theme.css</code>)</h3>



<p class="wp-block-paragraph">將所有深色模式樣式統一抽離至獨立檔案，避免直接污染原作者的 <code>style.css</code>，大幅降低後續維護與合併時的衝突風險。</p>



<pre class="wp-block-code"><code>/* ==========================================================================
   Dark Theme Styles
   ========================================================================== */

/* 全域背景與文字 */
body.theme-dark {
  background-color: #121417;
  color: #c9d1d9;
}

body.theme-dark h1,
body.theme-dark h2,
body.theme-dark h3,
body.theme-dark h4,
body.theme-dark h5,
body.theme-dark h6 {
  color: #f0f6fc;
}

/* 頂部導覽、底欄與行動端選單 */
body.theme-dark .head-mast,
body.theme-dark .head-outro,
body.theme-dark .navigation-mobile-wrap {
  background-color: #181b20;
  color: #c9d1d9;
}

/* 下拉選單 */
body.theme-dark .navigation-main ul {
  background-color: #1a1e24;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.45);
  border: 1px solid #2b3036;
}

/* 輸入框與表單元件 */
body.theme-dark input&#91;type="text"],
body.theme-dark input&#91;type="search"],
body.theme-dark select,
body.theme-dark textarea {
  background-color: #1a1e24;
  color: #e6edf3;
  border-color: #343a42;
}

/* 主題切換按鈕與 SVG 圖示切換 */
.theme-switch-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: transparent;
  border: 0;
  cursor: pointer;
  color: inherit;
  transition: color 0.18s ease, background-color 0.18s ease;
}

.theme-switch-icon svg {
  width: 18px;
  height: 18px;
  fill: currentColor;
}

/* 淺色模式顯示月亮，深色模式顯示太陽 */
.theme-switch-icon .icon-sun { display: none; }
.theme-switch-icon .icon-moon { display: inline-block; }
body.theme-dark .theme-switch-icon .icon-sun { display: inline-block; }
body.theme-dark .theme-switch-icon .icon-moon { display: none; }</code></pre>



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



<h3 class="wp-block-heading">2. 行為邏輯：偵測與持久化 (<code>js/theme-switch.js</code>)</h3>



<p class="wp-block-paragraph">透過原生 JavaScript 實現無相依性（Vanilla JS）的偵測與 Cookie 儲存：</p>



<pre class="wp-block-code"><code>(function () {
  'use strict';

  var COOKIE_NAME = 'guntenbee_theme';
  var COOKIE_DAYS = 365;

  function getCookie(name) {
    var match = document.cookie.match(new RegExp('(^|;\\s*)' + name + '=(&#91;^;]+)'));
    return match ? decodeURIComponent(match&#91;2]) : null;
  }

  function setCookie(name, value, days) {
    var expires = '';
    if (days) {
      var date = new Date();
      date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
      expires = '; expires=' + date.toUTCString();
    }
    document.cookie = name + '=' + encodeURIComponent(value) + expires + '; path=/; SameSite=Lax';
  }

  function getSystemTheme() {
    return (window.matchMedia &amp;&amp; window.matchMedia('(prefers-color-scheme: dark)').matches)
      ? 'dark'
      : 'light';
  }

  function getPreferredTheme() {
    var savedTheme = getCookie(COOKIE_NAME);
    return (savedTheme === 'dark' || savedTheme === 'light') ? savedTheme : getSystemTheme();
  }

  function applyTheme(theme) {
    var isDark = (theme === 'dark');
    document.body.classList.toggle('theme-dark', isDark);
    document.body.classList.toggle('theme-light', !isDark);
    document.documentElement.classList.toggle('theme-dark', isDark);
    document.documentElement.classList.toggle('theme-light', !isDark);

    var toggles = document.querySelectorAll('.theme-switch-toggle');
    toggles.forEach(function (btn) {
      btn.setAttribute('aria-pressed', isDark ? 'true' : 'false');
      btn.setAttribute('title', isDark ? '切換至淺色模式' : '切換至深色模式');
    });
  }

  function init() {
    applyTheme(getPreferredTheme());

    document.addEventListener('click', function (e) {
      var toggle = e.target.closest('.theme-switch-toggle');
      if (toggle) {
        e.preventDefault();
        var currentTheme = document.body.classList.contains('theme-dark') ? 'dark' : 'light';
        var nextTheme = (currentTheme === 'dark') ? 'light' : 'dark';
        setCookie(COOKIE_NAME, nextTheme, COOKIE_DAYS);
        applyTheme(nextTheme);
      }
    });

    // 監聽作業系統外觀模式動態變化
    if (window.matchMedia) {
      window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
        if (!getCookie(COOKIE_NAME)) {
          applyTheme(getSystemTheme());
        }
      });
    }
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();</code></pre>



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



<h3 class="wp-block-heading">3. WordPress 資源載入與防閃爍 (<code>inc/scripts-styles.php</code>)</h3>



<p class="wp-block-paragraph">在 <code>wp_enqueue_scripts</code> 鉤子中註冊資源，並加入極速 Inline 腳本：</p>



<pre class="wp-block-code"><code>// Enqueue 樣式與腳本
wp_register_style( 'gutenbee-dark-theme', get_theme_file_uri( '/css/dark-theme.css' ), array( 'gutenbee-style' ), gutenbee_asset_version() );
wp_enqueue_style( 'gutenbee-dark-theme' );

// 防閃爍腳本：在頁面剛開始解析 DOM 時搶先將 theme-dark 注入 &lt;html&gt;
$anti_flicker_js = "(function(){try{var m=document.cookie.match(/(^|;\\s*)guntenbee_theme=(&#91;^;]+)/);var t=m?decodeURIComponent(m&#91;2]):null;var d=(window.matchMedia&amp;&amp;window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';if((t||d)==='dark'){document.documentElement.classList.add('theme-dark');}}catch(e){}})();";
wp_add_inline_script( 'gutenbee-dependencies', $anti_flicker_js, 'before' );

wp_register_script( 'gutenbee-theme-switch', get_theme_file_uri( '/js/theme-switch.js' ), array(), gutenbee_asset_version(), true );
wp_enqueue_script( 'gutenbee-theme-switch' );</code></pre>



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



<h3 class="wp-block-heading">4. 範本標籤與按鈕輸出 (<code>inc/template-tags.php</code> &amp; Header Templates)</h3>



<ol class="wp-block-list">
<li>定義輔助函數：<code>function gutenbee_the_theme_switch_button() { ?> &lt;button type="button" class="theme-switch-toggle" aria-label="切換深淺模式"> &lt;span class="theme-switch-icon" aria-hidden="true"> &lt;svg class="icon-moon" viewBox="0 0 24 24">&lt;path d="M12.3 2a10 10 0 0 0-.19 14 10 10 0 0 0 11.63 4A10 10 0 1 1 12.3 2z"/>&lt;/svg> &lt;svg class="icon-sun" viewBox="0 0 24 24">&lt;circle cx="12" cy="12" r="5"/>&lt;line x1="12" y1="1" x2="12" y2="3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>&lt;line x1="12" y1="21" x2="12" y2="23" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>&lt;line x1="4.22" y1="4.22" x2="5.64" y2="5.64" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>&lt;line x1="18.36" y1="18.36" x2="19.78" y2="19.78" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>&lt;line x1="1" y1="12" x2="3" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>&lt;line x1="21" y1="12" x2="23" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>&lt;line x1="4.22" y1="19.78" x2="5.64" y2="18.36" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>&lt;line x1="18.36" y1="5.64" x2="19.78" y2="4.22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>&lt;/svg> &lt;/span> &lt;/button> &lt;?php }</code></li>



<li>在 <code>template-parts/header/header-full.php</code>、<code>header-split.php</code>、<code>header-navbar.php</code> 的 <code>&lt;div class="head-mast-inner"></code> 內部直接呼叫 <code>gutenbee_the_theme_switch_button()</code> 即可完美呈現。</li>
</ol>



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



<h2 class="wp-block-heading">三、未來原作者升級主題時的處理策略</h2>



<p class="wp-block-paragraph">當原主題作者發布新版本更新時，若直接覆蓋整個主題資料夾，客製化代碼將會遺失。以下提供三種不同維護情境的應對方式：</p>



<h3 class="wp-block-heading">策略 A：建立子主題（Child Theme）—— 永遠不被覆蓋（推薦最佳實踐）</h3>



<p class="wp-block-paragraph">WordPress 的標準升級防禦機制是子主題。只需將新寫好的 <code>dark-theme.css</code>、<code>theme-switch.js</code> 及對應 template parts 放進子主題資料夾，日後父主題無論升級幾次，客製化邏輯都不會被覆蓋。</p>



<h3 class="wp-block-heading">策略 B：利用 Git Patch 自動打補丁（零 Token 快速升級）</h3>



<p class="wp-block-paragraph">若習慣直接於主題目錄工作，建議在功能完成時立即產生 patch 檔案備份：</p>



<pre class="wp-block-code"><code># 產出補丁檔
git diff &gt; dark-theme.patch</code></pre>



<p class="wp-block-paragraph">當日後原作者有新版發布時：</p>



<pre class="wp-block-code"><code># 下載並解壓縮覆蓋原主題後，執行一鍵套用
git apply --reject --whitespace=fix dark-theme.patch</code></pre>



<p class="wp-block-paragraph"><code>git apply</code> 具備自動比對行號上下文與建立新檔案的能力，速度快且完全不花費任何 AI token。</p>



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



<h3 class="wp-block-heading">策略 C：低 Token 的精準 AI 提示詞（AI Prompt Template）</h3>



<p class="wp-block-paragraph">若在遠端或未配備 Git 的環境，需請 AI 助理重新在原作者新版主題植入 Dark Theme 功能時，<strong>千萬不要使用模糊指令</strong>（例如「<em>幫我在這個主題加深色模式</em>」，這會讓 AI 花費數萬 Token 去探索全文結構與測試選擇器）。</p>



<p class="wp-block-paragraph">直接複製使用以下經結構化優化的 <strong>精準 Prompt</strong>：</p>



<pre class="wp-block-code"><code>請為 @guntenbee 主題實作 Dark Theme 功能。請直接依照以下規格建立檔案與注入代碼，不需漫遊搜尋其他檔案：

1. 新增樣式與腳本：
   - 建立 `css/dark-theme.css`：定義 `body.theme-dark` 樣式（深色底色 #121417、文字 #c9d1d9、容器/選單 #181b20）與 `.theme-switch-toggle` 按鈕 SVG 切換動態。
   - 建立 `js/theme-switch.js`：讀取/寫入 Cookie `guntenbee_theme`（效期 365 天）；若無 Cookie 則以 `prefers-color-scheme: dark` 為準，並監聽 OS 主題變化與按鈕點擊事件。

2. 註冊與防閃爍（修改 `inc/scripts-styles.php`）：
   - 在 `gutenbee_enqueue_scripts` 中以 `wp_enqueue_style` 載入 `dark-theme.css`。
   - 注入 Inline Script 到 `&lt;head&gt;`：若 Cookie 或系統偏好為 dark，即時加上 `document.documentElement.classList.add('theme-dark')` 以防止白光閃爍。
   - 以 `wp_enqueue_script` 載入 `theme-switch.js`。

3. 按鈕與鉤子整合：
   - 在 `inc/template-tags.php` 新增 `gutenbee_the_theme_switch_button()` 輸出太陽/月亮按鈕 HTML。
   - 在 `template-parts/header/header-*.php` 各樣板的 `&lt;div class="head-mast-inner"&gt;` 的 `head-content-slot` 中呼叫該按鈕函式。
   - 在 `inc/default-hooks.php` 的 `gutenbee_body_class` 過濾器中檢查 `$_COOKIE&#91;'guntenbee_theme']`，有 dark 值即附加 `theme-dark` class。</code></pre>



<h4 class="wp-block-heading">為什麼這個 Prompt 具備高性價比（Low Token Usage）？</h4>



<ol class="wp-block-list">
<li><strong>消除探勘步驟</strong>：精確指出檔案名稱（如 <code>inc/scripts-styles.php</code>、<code>template-parts/header/header-*.php</code>），AI 不需調用目錄搜尋工具消耗大量 Context Window。</li>



<li><strong>精準函式命名</strong>：直接給予目標鉤子（<code>gutenbee_body_class</code>、<code>gutenbee_enqueue_scripts</code>），AI 思考路徑縮短，輸出 token 減少 70% 以上。</li>



<li><strong>成果可預測且易於維護</strong>：模組化規範使每一次生成的樣式結構一致，日後維護與問題排查極為單純。</li>
</ol>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/10/wordpress-dark-mode-git-patch-prompt/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Qwen-Image-2.1-Turbo 加速版的教學：文字生圖與局部修改</title>
		<link>https://stackoverflow.max-everyday.com/2026/10/qwen-image-2-1-turbo/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/10/qwen-image-2-1-turbo/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Mon, 12 Oct 2026 09:23:32 +0000</pubDate>
				<category><![CDATA[電腦相關應用]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8786</guid>

					<description><![CDATA[Qwen-Image-2.1-Turbo 釋出八...]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Qwen-Image-2.1-Turbo 釋出八步生成與圖片編輯權重。本文整理官方環境要求、預存取樣時程、商品圖提示詞與逐張驗收方法，並說明研究授權、CUDA 範例及商用前要確認的條件。</p>



<h3 class="wp-block-heading">核心架構與技術特點</h3>



<p class="wp-block-paragraph">這個模型基於七十億參數的視覺生成架構，採用三十二層的 Single-Stream DiT 設計。與需要二十五到四十步推理的標準基礎模型不同，Turbo 版本透過特殊的加速機制與預存取樣時程，將去噪步驟大幅縮短至八步。它的預設無分類器引導係數固定為一，省去了額外的前向計算過程，因此能以極快的速度完成推理。</p>



<h3 class="wp-block-heading">主要功能</h3>



<p class="wp-block-paragraph">模型支援文字生成圖片，能夠產出高達 2K 解析度的畫面。除了文生圖之外，它也具備強大的圖片編輯能力。使用者可以透過自然語言指令、參考圖、塗繪標註或蒙版，對既有圖像進行局部修改、更換背景、增加配件或調整場景，同時保留主體與商品的身份特徵。此外，它也支援原生透明度處理。</p>



<h3 class="wp-block-heading">應用場景</h3>



<p class="wp-block-paragraph">由於其八步快速出圖的特性，這個模型非常適合需要頻繁反覆修改、快速產出草稿或進行電商商品圖批次處理的工作流程。開發者通常會透過 Hugging Face 的 Diffusers 套件以及 ComfyUI 等介面來載入與部署這款模型。</p>



<h3 class="wp-block-heading">兩者的核心差異</h3>



<p class="wp-block-paragraph">Qwen/Qwen-Image-2.1-Turbo 是 Qwen/Qwen-Image-2.1 的蒸餾加速版本。兩者在底層使用了相同的七十億參數 Single-Stream DiT 視覺生成架構，但推理機制與速度上有顯著不同：<sup></sup><sup></sup><sup></sup><sup></sup></p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><td><strong>比較項目</strong></td><td><strong>Qwen/Qwen-Image-2.1（標準原版）</strong></td><td><strong>Qwen/Qwen-Image-2.1-Turbo（加速版）</strong></td></tr></thead><tbody><tr><td>推理步數<sup></sup><sup></sup><sup></sup><sup></sup></td><td>需要 25 至 40 步<sup></sup><sup></sup><sup></sup><sup></sup></td><td>僅需 8 步（內建預設排程）<sup></sup><sup></sup><sup></sup><sup></sup></td></tr><tr><td>無分類器引導（CFG）<sup></sup><sup></sup><sup></sup><sup></sup></td><td>需要設定 CFG &gt; 1（需雙倍前向計算）<sup></sup><sup></sup><sup></sup><sup></sup></td><td>預設 CFG = 1（嵌入權重，無<sup></sup>額外計算）<sup></sup><sup></sup><sup></sup></td></tr><tr><td>生成速度<sup></sup><sup></sup><sup></sup></td><td>較慢，但控制精細度高<sup></sup><sup></sup><sup></sup></td><td>快約<sup></sup> 5 至 8 倍，非常適合快速預覽與大量生成<sup></sup><sup></sup></td></tr><tr><td>提示詞遵循能力<sup></sup><sup></sup></td><td>極佳，適合<sup></sup>長文字或複雜多指令提示<sup></sup></td><td>稍遜於原版，極複雜指令可能有些微<sup></sup>調偏差</td></tr></tbody></table></figure>



<h3 class="wp-block-heading">Turbo 可以完全覆蓋原版的所有功能嗎</h3>



<p class="wp-block-paragraph">答案是：在功能支援上可以完全覆蓋，但在極限品質與控制力上無法完全取代原版。</p>



<p class="wp-block-paragraph"><strong>功能範疇完全重疊：</strong></p>



<p class="wp-block-paragraph">Turbo 版本完全繼承了原版的所有核心功能，包括文字生成圖片、透明背景 RGBA 圖層生成、多圖參考編輯、塗繪與蒙版局部修改、以及人物與商品特徵保留。在 Diffusers 程式庫中，兩者也是使用同一個管道載入。</p>



<p class="wp-block-paragraph"><strong>無法完全取代原版的特定場景：</strong></p>



<ul class="wp-block-list">
<li>極複雜提示詞：Turbo 版本透過蒸餾技術將引導資訊固化在權重中。當提示詞包含非常長且繁複的多層次指令時，標準原版在 40 步與高 CFG 下的細節表現仍會比 Turbo 更精準。</li>



<li>自由調整參數：原版允許使用者自由調整係數與微調取樣步數，以在創造力與文字貼合度之間找到最佳平衡。Turbo 版本的排程與係數已被鎖定與最佳化，調參彈性較低。</li>
</ul>



<h3 class="wp-block-heading">使用建議</h3>



<ul class="wp-block-list">
<li>如果需要高效率生產、電商批次處理、即時互動生成或快速草稿設計，應優先選擇 Turbo。</li>



<li>如果需要處理非常精細的藝術設計、極度複雜的邏輯指令，或是追求極致細節的最終成品，建議保留原版。</li>
</ul>



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



<h2 class="wp-block-heading">Q: 顯示卡 RTX 4050 Laptop 6GB VRAM 可以跑的動?</h2>



<p class="wp-block-paragraph">以預設的標準精度（BF16 或 FP16）來看，Qwen-Image-2.1 系列模型大約需要十六到二十九 GB 以上的顯示記憶體，因此 RTX 4050 Laptop 的六 GB 顯示記憶體無法直接流暢運行原生日誌與完整精度。</p>



<p class="wp-block-paragraph">不過，如果你透過以下幾種優化方式，這張顯示卡還是有機會可以嘗試本地端運行：</p>



<ul class="wp-block-list">
<li>降低精度與量化：透過 GGUF 量化版本或 INT4、INT8 壓縮權重，將模型體積縮小，讓記憶體需求降至六 GB 甚至更低，但這會犧牲部分生成畫質。</li>



<li>善用系統記憶體（RAM）：如果筆電的共用記憶體與主記憶體足夠大，部分運行框架支援將部分運算卸載到 CPU 或系統記憶體，只是生成速度會變得很慢。</li>



<li>雲端算力替代：如果本地運行經常遇到顯示記憶體不足（Out of Memory）的錯誤，建議改用 Google Colab 或雲端 GPU 服務來執行。</li>
</ul>



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



<h2 class="wp-block-heading">Q: 如何找到降低精度與量化的版本?</h2>



<p class="wp-block-paragraph">在 Hugging Face 或開源社群尋找像 Qwen-Image-2.1 系列這種大型 DiT 模型的量化（Quantized）或降低精度版本時，主要有以下幾條途徑與搜尋方法：</p>



<h3 class="wp-block-heading">一、 直接在 Hugging Face 搜尋對應的量化庫</h3>



<p class="wp-block-paragraph">量化版本通常不會放在官方原版的 repo 中，而是由社群創作者（如 Unsloth、city96、leejet 等）轉換後發布。你可以直接在 Hugging Face 的搜尋列輸入以下關鍵字：</p>



<ul class="wp-block-list">
<li><strong>GGUF 版本（最適合 6GB VRAM 使用）：</strong>
<ul class="wp-block-list">
<li>搜尋：<code>Qwen-Image-2.1 GGUF</code> 或 <code>Qwen-Image-2.1-Turbo GGUF</code></li>



<li>核心量化層級選擇：
<ul class="wp-block-list">
<li><code>Q4_K_M</code>（約 4.2 GB）：最推薦 RTX 4050 6GB 使用，模型可以直接完全載入顯示記憶體。</li>



<li><code>Q5_0</code> / <code>Q5_K_M</code>（約 5.0 &#8211; 5.3 GB）：細節稍微提升，但可能會用到少許系統記憶體（RAM）做緩衝。</li>
</ul>
</li>
</ul>
</li>



<li><strong>INT8 / NF4 版本（Comfy-Org 官方或社群微調）：</strong>
<ul class="wp-block-list">
<li>搜尋：<code>Qwen-Image-2.1-Turbo-INT8</code> 或 <code>qwen_image_2.1_turbo_int8_convrot</code></li>



<li>這是直接將權重壓成 INT8 格式的 safetensors 檔案，適合傳統 ComfyUI 流程。</li>
</ul>
</li>
</ul>



<h3 class="wp-block-heading">二、 搭配 ComfyUI 運行 GGUF 量化版（最建議的做法）</h3>



<p class="wp-block-paragraph">若要在 RTX 4050 上順利運行 GGUF 模型，單靠預設的 ComfyUI 節點可能會遇到「不支援架構」的錯誤。推薦使用支援該模型架構的擴充元件與組件：</p>



<ol start="1" class="wp-block-list">
<li><strong>安裝自訂節點：</strong>使用 <code>leejet</code> 維護的 <code>ComfyUI-GGUF</code> 套件（比舊版 city96 能更完善支援 Qwen 2.1 架構）。</li>



<li><strong>下載配套檔案：</strong>
<ul class="wp-block-list">
<li><strong>UNet / Diffusion 模型：</strong> 放至 <code>ComfyUI/models/unet/</code>，選擇下載好的 <code>.gguf</code> 檔案（例如 <code>qwen-image-2.1-Q4_K_M.gguf</code>）。</li>



<li><strong>Text Encoder（文本編碼器）：</strong>Qwen-Image 2.1 搭配的是 8B 的文本編碼器。為了省記憶體，同樣建議尋找量化過的文字編碼器，如 <code>qwen3vl_8b_w4a8.safetensors</code> 或 <code>qwen3vl_8b_int8</code> 並放入 <code>models/text_encoders/</code>。</li>
</ul>
</li>



<li><strong>替換節點：</strong>在 ComfyUI 工作流中，將原本的 <code>Load Diffusion Model</code> 替換為 <code>Unet Loader (GGUF)</code> 即可載入。</li>
</ol>



<h3 class="wp-block-heading">三、 使用整合型工具（如 Ollama / LM Studio 等，若支援）</h3>



<p class="wp-block-paragraph">如果不想手動設定 ComfyUI 工作流，也可以關注像是 <strong>Draw Things</strong>（Mac/iOS 支援，部分 Windows 框架亦有移植）或 <strong>Civitai</strong> 網站上是否有創作者上傳整理好的單一打包模型，通常搜尋欄位輸入 <code>Qwen-Image-2.1 Turbo Quantized</code> 即可找到相關資源。</p>



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



<h2 class="wp-block-heading"><a href="https://huggingface.co/unsloth/Qwen-Image-2.1-Turbo-GGUF">https://huggingface.co/unsloth/Qwen-Image-2.1-Turbo-GGUF</a></h2>



<p class="wp-block-paragraph">which version best for my RTX 4050 Laptop 6GB RAM?</p>



<p class="wp-block-paragraph">▋ 最推薦的版本：Q4_K_M (4.2 GB)<sup></sup></p>



<p class="wp-block-paragraph">對於 RTX 4050 Laptop 6GB 顯示記憶體來說，<strong>Q4_K_M 是兼顧品質與運行成功率的最佳首選</strong>。</p>



<p class="wp-block-paragraph">▋ 各版本在 6GB 顯示記憶體下的實測建議與分析</p>



<p class="wp-block-paragraph">在生成圖片時，顯示記憶體（VRAM）除了要存放主要的 DiT 模型權重之外，還必須留出空間給：</p>



<ol start="1" class="wp-block-list">
<li><strong>Text Encoder（文本編碼器，如 Qwen3-VL 8B）：</strong> 需額外佔用約 0.7 &#8211; 2 GB 顯示記憶體。</li>



<li><strong>VAE 解碼器與 CUDA 計算緩衝區（Compute Buffers）：</strong> 約需 1.0 &#8211; 1.5 GB。</li>
</ol>



<p class="wp-block-paragraph">因此，模型的檔案大小不能剛好等於 6 GB，必須留出至少 1.5 &#8211; 2 GB 的動態開銷空間。</p>



<ul class="wp-block-list">
<li><strong>Q4_K_M (4.2 GB) —— 最平衡首選</strong>
<ul class="wp-block-list">
<li><strong>分析：</strong>模型本身佔用約 4.2 GB。搭配 ComfyUI 的模型卸載（CPU Offloading）機制，可以在 6GB VRAM 內剛好塞下，避免發生 OOM（Out of Memory）記憶體溢位崩潰。</li>



<li><strong>畫質損耗：</strong> Unsloth 的 Dynamic 4-bit 量化對畫面細節與文字遵循能力保留得相當完整，幾乎感受不出明顯品質下降。</li>
</ul>
</li>



<li><strong>Q4_K_S (3.91 GB) —— 備用方案（記憶體不夠時使用）</strong>
<ul class="wp-block-list">
<li><strong>分析：</strong> 檔案更小，如果你的系統背景程式佔用了較多 VRAM（例如開著 Chrome 或多個螢幕），導致 Q4_K_M 報錯，請改用此版本。</li>
</ul>
</li>



<li><strong>Q5_K_S / Q5_K_M (4.5 GB &#8211; 5.39 GB) —— 邊緣臨界，易爆 VRAM</strong>
<ul class="wp-block-list">
<li><strong>分析：</strong>4.5 GB 以上的檔案加上 VAE 與編碼器開銷後，非常容易超過 6GB 限制。若要硬跑，系統會被迫將部分模型卸載到電腦的主記憶體（RAM），導致生成速度變得很慢。</li>
</ul>
</li>



<li><strong>Q6_K / Q6_K_XL (6.27 GB &#8211; 6.72 GB) —— 完全不建議</strong>
<ul class="wp-block-list">
<li><strong>分析：</strong>單模型檔案大小就已超過你的顯示記憶體上限。</li>
</ul>
</li>
</ul>



<p class="wp-block-paragraph">▋ 6GB VRAM 順暢運行的關鍵設定</p>



<ol start="1" class="wp-block-list">
<li><strong>圖形解析度：</strong>請先從 <strong>1024&#215;1024</strong> 開始生成。切勿一開始就設為 2048&#215;2048。</li>



<li><strong>Text Encoder (文本編碼器)：</strong>建議下載量化過的文字編碼器（如 <code>qwen3vl_8b_w4a8</code> 或 <code>int8</code>），或者在 ComfyUI 中開啟 <strong>CPU Text Encoder</strong> 模式，把文字處理丟給電腦主記憶體。</li>



<li><strong>VAE 設定：</strong>在 ComfyUI 的 VAE Loader 設定中開啟 <strong>Tiled VAE</strong>（區塊解碼），可以大幅節省生成最後階段的 VRAM 峰值用量。</li>
</ol>



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



<h2 class="wp-block-heading">Q: 如何串接 AI agent CLI 生圖</h2>



<p class="wp-block-paragraph">要把文字互動介面與本地端生圖模型串接起來，主要有兩個常見且可行的解法。</p>



<p class="wp-block-paragraph">第一種是透過 ComfyUI 內建的 API 伺服器來串接。ComfyUI 啟動後會在背景提供一套完整的網頁伺服器與 API 功能。你可以在終端機裡寫一個簡單的 Python 程式或是用 curl 指令，把提示詞打包成 JSON 格式發送到 ComfyUI 的連接埠，讓它在背景自動生成圖片並回傳檔案路徑。驗證這個步驟是否成功的方法是，打開瀏覽器確認網頁伺服器有正常回應。</p>



<p class="wp-block-paragraph">第二種是自己寫一個輕量化的轉接層。有些 agent cli 工具預設只能對接類似 OpenAI 的文字或圖片生成 API。你可以用 Python 的 FastAPI 寫一個簡單的伺服器，對外提供標準的 API 介面，當收到終端機傳來的生圖請求時，程式再自動去呼叫底層的生圖工具。驗證這個步驟是否成功的方法是，用終端機發送一筆測試請求，確認伺服器能夠正確接收並處理。</p>



<h2 class="wp-block-heading">give me solution for my RTX 4050 Laptop 6GB VRAM</h2>



<p class="wp-block-paragraph">你的顯示記憶體只有 6GB ，直接執行官方的原始程式碼一定會立刻跳出記憶體不足的錯誤。為了讓程式碼能在你的電腦上順利跑完，我們必須修改幾個關鍵設定，包含大幅降低圖片解析度以及開啟 CPU 記憶體卸載功能。</p>



<p class="wp-block-paragraph">這是一份修改過後的 Python 程式碼，可以讓你在 RTX 4050 上面嘗試執行：</p>



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



<pre class="wp-block-code"><code>import torch
from diffusers import QwenImage21Pipeline

pipe = QwenImage21Pipeline.from_pretrained(
    "Qwen/Qwen-Image-2.1-Turbo",
    torch_dtype=torch.float16,
)

pipe.enable_model_cpu_offload()

prompt = "A vertically oriented educational chemistry study poster fills the frame, designed like a hand-drawn classroom notes page with a clean white background, thick dark navy-blue outer border, and thin inner navy rectangular border lines. The overall aesthetic is colorful, high-contrast, and hand-lettered in a signature Topper style using marker and watercolor effects. At the top left, a small header in black handwriting reads \"Topper’s Notes\", accented by short decorative navy strokes radiating from it. Centered across the top is the main lesson heading in large stylized black-and-yellow bold lettering: \"Science – Chapter 1:\" followed below by the much larger title \"Chemical Reactions and Equations\". The title has energetic yellow highlighter strokes underneath and small sparkle doodles nearby. At the top right, inside a small outlined page-number box, the label reads \"Page X\". Below the header, a wide rounded rectangle framed in dark navy contains a pink brush-stroke highlight banner with the subheading \"IMPORTANT CHEMICAL EQUATION:\" in black. Inside the same equation panel, the reaction is written very large as \"Fe + CuSO₄ → FeSO₄ + Cu\". The symbol \"Fe\" is highlighted in bright orange with an underline, \"CuSO₄\" is vivid blue and underlined, \"FeSO₄\" is bright green and underlined, and \"Cu\" is metallic bronze and underlined. Beneath the left side of the equation is a lavender rounded label connected by a leader line to the iron component; it says \"Reactants\" and below it \"(before the reaction)\", with \"Reactants\" colored purple and underlined. Beneath the right side is a matching pale-green rounded label connected by a leader line to the products; it says \"Products\" and below it \"(after the reaction)\", with \"Products\" colored green and underlined. Along the lower part of this equation box are stylized ball-and-stick molecule models and atomic orbital drawings: a silver-gray sphere labeled \"Fe\" sits left of center, another cluster shows gray, red, and white spheres representing sulfate groups around blue and green atoms, and a copper-colored sphere appears near the right. Small atom-and-orbit icons sit along the bottom edge of the panel in blue, green, and brown outline styles. The middle portion is enclosed by a large dashed green rounded rectangle titled \"REACTIVITY CONCEPT\" in green uppercase letters with decorative rays. On the left side of this section, a stepped vertical reactivity-series ladder is drawn with alternating colored horizontal blocks. From top to bottom it lists \"K\", \"Na\", \"Ca\", \"Mg\", \"Al\", \"Zn\", \"Fe\" with a green oval callout reading \"(More reactive)\", then continues downward with \"Cu\", \"Ag\", \"Hg\", \"Pt\", \"Au\" with an orange oval callout reading \"(Less reactive)\". Each step is marked by a dark upward-pointing triangular arrow, showing decreasing reactivity downward. In the central-right area, full body text as follows: \"Iron (Fe) is higher in the reactivity series than Copper (Cu). This makes Iron more reactive.\" Below this definition is a visual arrow sequence of overlapping circles: orange \"Fe\" overlaps blue \"Cu\", followed by a red arrow pointing toward the phrase \"→ More displacement ↓ less reactivity\". Under it, a prominent statement in dark handwriting reads \"More reactive element displaces less reactive element\", emphasized with a bright pink marker underline. On the right, a shield-like badge with a navy outline and light interior contains the bold contrasting font label \"DISPLACEMENT REACTION\". Below the badge, full body text as follows: \"A more reactive element displaces a less reactive element from its compound.\" The badge includes an internal miniature illustration of circular electron shells and colored particles, reinforcing the chemistry theme. Directly below the reactivity concept box, a smaller rounded rectangular table titled \"PROPERTY COMPARISON\" compares Iron and Copper. The table has three columns: \"IRON (Fe)\", \"COPPER (Cu)\", and a central property column. Row one reads \"Reactivity series position\" with Iron shown as \"Higher (more reactive)\" and Copper shown as \"Lower (less reactive)\". Row two reads \"Displaces hydrogen from acids\" with Iron marked \"Yes\" and Copper marked \"No\". Row three reads \"Displaced by other metals\" with Iron shown as \"Displaced by Zn, Mg, Al, etc.\" and Copper shown as \"Displaced by Fe, Zn, Mg, etc.\". Row four reads \"Oxidation tendency\" with Iron shown as \"Gets oxidized easily\" and Copper shown as \"Does not get oxidized easily\". Row five reads \"Useful property\" with Iron described as \"Used where strength &amp; corrosion resistance needed\" and Copper described as \"Used where conductivity &amp; corrosion resistance needed\". The table uses thin navy grid lines, orange tinting for the iron column, pale blue tinting for the copper column, black condensed handwriting, and green checkmarks next to positive traits and red crosses next to limitations. The lower half introduces a large illustrated experiment section with a cream highlight behind the title \"BEFORE/AFTER IRON NAIL EXPERIMENT\" in bold dark navy uppercase letters. The section is split visually into a left \"Before\" state and a right \"After\" state. On the left, a beaker filled with bright blue solution contains a shiny silver iron nail standing diagonally. The beaker label reads \"CuSO₄(aq)\" in blue, and the bottom label near the nail reads \"Fe(s)\". A rounded annotation box beside the beaker says \"Initial State: Blue Solution &amp; Silver Nail\". Two glass test tubes stand to the right of this beaker: the upper test tube is filled with blue solution and labeled \"CuSO₄(aq)\", while the lower one contains a pale green liquid. Red and blue directional arrows connect these visual elements to explanations: one red arrow points toward the nail with the text \"Copper gets deposited\", and one blue arrow points toward the liquid with the text \"Solution turns pale green\". On the right side of the experiment section, another beaker shows the after state with the iron nail now coated in brown copper deposits. Its solution is faded to a pale greenish-blue, and the beaker label reads \"FeSO₄(aq)\" in green. A nearby rounded annotation box says \"Final State: Pale Green Solution &amp; Brown Coated Nail\". To the far right are additional test tubes and ion symbols: a test tube containing blue liquid labeled \"CuSO₄(aq)\" with the note \"Blue colour due to Cu²⁺ ions\", and a lower test tube containing pale green liquid labeled \"FeSO₄(aq)\" with the note \"Pale green due to Fe²⁺ ions\". Beside them are ion diagrams labeled \"Cu²⁺(aq)\" and \"Fe²⁺(aq)\", shown as colored charged particles encircled by simple electron-shell rings; the copper ion uses blue particles and the iron ion uses green particles. Small sparkles, arrows, and dashed guide lines emphasize the transformation from blue copper sulfate solution to pale green iron sulfate solution. Near the bottom, a horizontal summary strip has a blue star icon followed by the heading \"SUMMARY\". Full body text as follows: \"In this reaction, Iron (Fe) displaces Copper (Cu) from Copper Sulphate (CuSO₄) solution to form Iron(II) Sulphate (FeSO₄) solution and Copper (Cu).\" To the lower left, a bordered key-points box titled \"KEY POINTS\" lists three starred bullet statements: \"Iron is above copper in the reactivity series.\", \"Fe displaces Cu from CuSO₄ solution.\", and \"Result: Blue solution turns pale green; copper metal deposits on iron nail.\" At the very bottom, inside the footer border, spaced text reads \"CHEMISTRY • CHANGE IS EVERYWHERE • TOPPER’S NOTES\", flanked by small leaf-like ornaments and thin horizontal divider lines. The entire page uses dark navy borders and headings, bright highlighter strokes, watercolor shading, realistic glassware illustrations, and playful scientific doodles to present a chemistry lesson clearly and attractively."

image = pipe(
    prompt=prompt,
    width=512,
    height=768,
    use_kv_cache=True,
    generator=torch.Generator("cpu").manual_seed(42),
).images&#91;0]

image.save("t2i.png")</code></pre>



<p class="wp-block-paragraph">這段程式碼做了三個重要的調整。第一是把圖片解析度從原本極大的尺寸縮小成 512 乘以 768 像素，因為原本的尺寸需要十六 GB 以上的顯示記憶體才能運作。第二是加入了記憶體卸載指令，當顯示記憶體快滿時，會自動把運算暫存搬到電腦的主記憶體。第三是把資料型態改成了 float16 ，這樣可以省下一半的記憶體空間。</p>



<p class="wp-block-paragraph">因為這張顯示卡的記憶體容量有限，啟動記憶體卸載之後，生圖的速度會變得非常慢，需要耐心等待程式執行完畢。如果執行時還是出現記憶體不足的錯誤，可以把解析度再調得更小一點。</p>



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



<h2 class="wp-block-heading">Q: 上述範例 512&#215;768 會占用多少記憶體, 我如果下載 5-bit mode: Q5_K_S (4.5 GB), 會造成 OOM ?</h2>



<p class="wp-block-paragraph">在 <code>512x768</code> 解析度下，圖片本身的潛在空間與運算暫存區大約會佔用 <code>1 GB</code> 到 <code>1.5 GB</code> 的顯示記憶體。</p>



<p class="wp-block-paragraph">如果下載 <code>5-bit</code> 的 <code>Q5_K_S</code> 版本（檔案大小約 <code>4.5 GB</code>），在 <code>RTX 4050</code> 的 <code>6 GB</code> 顯示記憶體上極有可能會發生 <code>OOM</code> 錯誤，或是被迫將大量運算卸載到系統記憶體，導致生圖速度變得極慢。</p>



<p class="wp-block-paragraph">原因在於 <code>6 GB</code> 顯示記憶體扣掉 <code>4.5 GB</code> 的模型檔案後，只剩下大約 <code>1.5 GB</code> 的空間。然而生圖過程還需要載入文字編碼器與解碼器，這些元件加起來很容易超過 <code>6 GB</code> 的極限。</p>



<p class="wp-block-paragraph">建議還是選擇檔案更小的 <code>4-bit</code> 版本，例如 <code>Q4_K_M</code> 或 <code>Q4_K_S</code>，才能在 <code>6 GB</code> 顯示記憶體上比較穩定地運行。</p>



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



<h2 class="wp-block-heading">改寫範例scrpt 為 resetful api + fastapi, input prompt + output target filepath</h2>



<p class="wp-block-paragraph">I will now generate the FastAPI script for running the Qwen-Image-2.1-Turbo model as a RESTful service.</p>



<p class="wp-block-paragraph">I have created the FastAPI script <code>qwen_api_server.py</code>.</p>



<pre class="wp-block-code"><code>from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
import torch
from diffusers import QwenImage21Pipeline
import os

app = FastAPI(
    title="Qwen-Image-2.1-Turbo REST API",
    description="A lightweight FastAPI service to generate images via Qwen-Image-2.1-Turbo and save them to a specified target file path.",
    version="1.0.0"
)

# Global pipeline variable to hold the model in memory
pipe = None

@app.on_event("startup")
def load_model():
    global pipe
    print("Loading Qwen-Image-2.1-Turbo model into GPU memory...")
    try:
        pipe = QwenImage21Pipeline.from_pretrained(
            "Qwen/Qwen-Image-2.1-Turbo",
            torch_dtype=torch.bfloat16,
        ).to("cuda")
        # Uncomment the line below if you run into 6GB VRAM OOM issues:
        # pipe.enable_model_cpu_offload()
        print("Model loaded successfully!")
    except Exception as e:
        print(f"Failed to load model: {e}")
        raise e

class GenerateRequest(BaseModel):
    prompt: str = Field(..., description="The text description for image generation.")
    output_path: str = Field(..., description="The target file path where the generated image will be saved.")
    width: int = Field(512, description="Image width (recommended lower for 6GB VRAM, e.g., 512 or 768).")
    height: int = Field(768, description="Image height (recommended lower for 6GB VRAM, e.g., 768 or 1024).")
    seed: int = Field(42, description="Random seed for reproducibility.")

@app.post("/generate", status_code=status.HTTP_200_OK)
def generate_image(req: GenerateRequest):
    global pipe
    if pipe is None:
        raise HTTPException(status_code=500, detail="Model is not loaded yet.")
    
    try:
        # Ensure output directory exists if a subdirectory is provided
        output_dir = os.path.dirname(req.output_path)
        if output_dir and not os.path.exists(output_dir):
            os.makedirs(output_dir, exist_ok=True)

        # Run inference
        image = pipe(
            prompt=req.prompt,
            width=req.width,
            height=req.height,
            use_kv_cache=True,
            generator=torch.Generator("cpu").manual_seed(req.seed),
        ).images&#91;0]

        # Save to target filepath
        image.save(req.output_path)

        return {
            "status": "success",
            "message": f"Image successfully generated and saved.",
            "output_path": req.output_path
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Image generation failed: {str(e)}")

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("qwen_api_server:app", host="127.0.0.1", port=8000, reload=False)</code></pre>



<h3 class="wp-block-heading">How to use it:</h3>



<ol class="wp-block-list">
<li>Run the server using: <code>python qwen_api_server.py</code></li>



<li>You can send a POST request from your CLI agent (like <code>pi agent</code>, <code>agy cli</code>, or <code>curl</code>) to <code>[http://127.0.0.1:8000/generate](http://127.0.0.1:8000/generate)</code> with a JSON body containing your <code>prompt</code> and <code>output_path</code>.</li>
</ol>



<p class="wp-block-paragraph">Let me know if you want an example <code>curl</code> command or client script to test this API!</p>



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



<p class="wp-block-paragraph">FastAPI script for image editing using the Qwen-Image-2.1-Turbo pipeline, incorporating memory optimizations for your RTX 4050 6GB VRAM.</p>



<pre class="wp-block-code"><code>from fastapi import FastAPI, HTTPException, status
from pydantic import BaseModel, Field
import torch
from diffusers import QwenImage21Pipeline
from diffusers.utils import load_image
import os

app = FastAPI(
    title="Qwen-Image-2.1-Turbo Image Editing REST API",
    description="A FastAPI service for image-to-image / image editing using Qwen-Image-2.1-Turbo, optimized for 6GB VRAM.",
    version="1.0.0"
)

pipe = None

@app.on_event("startup")
def load_model():
    global pipe
    print("Loading Qwen-Image-2.1-Turbo pipeline for image editing into GPU memory...")
    try:
        pipe = QwenImage21Pipeline.from_pretrained(
            "Qwen/Qwen-Image-2.1-Turbo",
            torch_dtype=torch.float16, # Using float16 to conserve VRAM
        )
        
        pipe.enable_model_cpu_offload()
        print("Model and CPU offload successfully configured!")
    except Exception as e:
        print(f"Failed to load model: {e}")
        raise e

class EditRequest(BaseModel):
    prompt: str = Field(..., description="The editing text prompt describing the transformation.")
    input_image_path_or_url: str = Field(..., description="Local file path or URL of the source image/sketch.")
    output_path: str = Field(..., description="The target file path where the edited image will be saved.")
    width: int = Field(1024, description="Image width (scaled down to 1024 or 512 for 6GB VRAM safety).")
    height: int = Field(1024, description="Image height (scaled down to 1024 or 512 for 6GB VRAM safety).")
    seed: int = Field(20261009, description="Random seed for reproducibility.")

@app.post("/edit-image", status_code=status.HTTP_200_OK)
def edit_image(req: EditRequest):
    global pipe
    if pipe is None:
        raise HTTPException(status_code=500, detail="Model is not loaded yet.")
    
    try:
        if req.input_image_path_or_url.startswith("http://") or req.input_image_path_or_url.startswith("https://"):
            input_image = load_image(req.input_image_path_or_url).convert("RGBA")
        else:
            if not os.path.exists(req.input_image_path_or_url):
                raise HTTPException(status_code=400, detail=f"Input image path not found: {req.input_image_path_or_url}")
            input_image = load_image(req.input_image_path_or_url).convert("RGBA")

        # Ensure output directory exists
        output_dir = os.path.dirname(req.output_path)
        if output_dir and not os.path.exists(output_dir):
            os.makedirs(output_dir, exist_ok=True)

        image = pipe(
            prompt=req.prompt,
            image=input_image,
            width=req.width,
            height=req.height,
            use_kv_cache=True,
            generator=torch.Generator("cpu").manual_seed(req.seed),
        ).images&#91;0]

        # Save to target filepath
        image.save(req.output_path)

        return {
            "status": "success",
            "message": "Image editing completed and saved successfully.",
            "output_path": req.output_path
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Image editing failed: {str(e)}")

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("qwen_edit_api:app", host="127.0.0.1", port=8000, reload=False)</code></pre>



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



<h2 class="wp-block-heading">Q: /generate api and /edit-image api 怎麼寫會比較好, 如果後續還有其他 API 要增加.</h2>



<p class="wp-block-paragraph">如果要讓 FastAPI 專案未來方便擴充更多 API，建議採用模組化設計與依賴注入的概念。</p>



<p class="wp-block-paragraph">第一點是使用 APIRouter 來拆分路由。不要把所有端點都寫在 main.py 裡面。可以建立一個 routers 資料夾，把生圖功能放在 routers/generation.py，把圖片編輯放在 routers/editing.py，最後再用 app.include_router 整合進主程式。</p>



<p class="wp-block-paragraph">第二點是使用依賴注入管理模型實體。因為模型佔用大量顯示記憶體，不適合每次請求都重新載入。可以在程式啟動時初始化一次管線，透過 FastAPI 的 Depends 或全域狀態共用同一個 pipeline 執行個體。</p>



<p class="wp-block-paragraph">第三點是統一請求與回應格式。未來如果加入文字生成或其他功能，輸入與輸出的結構可以用 Pydantic 模型嚴格定義，這樣 agent 呼叫時才能確保欄位正確。</p>



<p class="wp-block-paragraph">專案資料夾結構可以這樣規劃：</p>



<pre class="wp-block-code"><code>qwen_img_fastapi/
│
├── main.py
├── services/
│   └── model_loader.py
└── routers/
    ├── generation.py
    └── editing.py</code></pre>



<p class="wp-block-paragraph">透過這種架構，未來如果需要新增其他 API，只要在 routers 裡面多寫一支檔案，並在 main.py 註冊即可，維護起來會更有條理。</p>



<p class="wp-block-paragraph"><strong>main.py</strong></p>



<pre class="wp-block-code"><code>from fastapi import FastAPI
from routers import generation, editing

app = FastAPI(
    title="Qwen-Image-2.1-Turbo Modular REST API",
    description="A modular FastAPI service for image generation and editing using Qwen-Image-2.1-Turbo, optimized for RTX 4050 6GB VRAM.",
    version="1.0.0"
)

# Include routers for different capabilities
app.include_router(generation.router)
app.include_router(editing.router)

@app.get("/", tags=&#91;"Root"])
def root():
    return {
        "status": "online",
        "message": "Welcome to Qwen-Image-2.1-Turbo API. Check /docs for Swagger UI documentation."
    }

if __name__ == "__main__":
    import uvicorn
    uvicorn.run("main:app", host="127.0.0.1", port=8000, reload=False)</code></pre>



<p class="wp-block-paragraph"><strong>services/model_loader.py</strong></p>



<pre class="wp-block-code"><code>import torch
from diffusers import QwenImage21Pipeline

# Global container for the shared pipeline
_pipe = None

def get_pipeline() -&gt; QwenImage21Pipeline:
    global _pipe
    if _pipe is None:
        print("Loading Qwen-Image-2.1-Turbo pipeline into memory...")
        try:
            _pipe = QwenImage21Pipeline.from_pretrained(
                "Qwen/Qwen-Image-2.1-Turbo",
                torch_dtype=torch.float16,
            )
            # Enable CPU offloading to prevent OOM errors on 6GB VRAM GPUs
            _pipe.enable_model_cpu_offload()
            print("Pipeline successfully loaded and CPU offload enabled!")
        except Exception as e:
            print(f"Failed to load pipeline: {e}")
            raise e
    return _pipe</code></pre>



<p class="wp-block-paragraph"><strong>routers/generation.py</strong></p>



<pre class="wp-block-code"><code>from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
import torch
import os
from services.model_loader import get_pipeline

router = APIRouter(prefix="/generation", tags=&#91;"Image Generation"])

class GenerateRequest(BaseModel):
    prompt: str = Field(..., description="The text description for image generation.")
    output_path: str = Field(..., description="The target file path where the generated image will be saved.")
    width: int = Field(512, description="Image width (keep low, e.g. 512 or 768 for 6GB VRAM).")
    height: int = Field(768, description="Image height (keep low, e.g. 768 or 1024 for 6GB VRAM).")
    seed: int = Field(42, description="Random seed for reproducibility.")

@app.post("/text-to-image" if False else "/generate", status_code=status.HTTP_200_OK)
def generate_image(req: GenerateRequest):
    try:
        pipe = get_pipeline()
        
        output_dir = os.path.dirname(req.output_path)
        if output_dir and not os.path.exists(output_dir):
            os.makedirs(output_dir, exist_ok=True)

        image = pipe(
            prompt=req.prompt,
            width=req.width,
            height=req.height,
            use_kv_cache=True,
            generator=torch.Generator("cpu").manual_seed(req.seed),
        ).images&#91;0]

        image.save(req.output_path)

        return {
            "status": "success",
            "message": "Image generated successfully.",
            "output_path": req.output_path
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Generation failed: {str(e)}")</code></pre>



<p class="wp-block-paragraph"><strong>routers/editing.py</strong></p>



<pre class="wp-block-code"><code>from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
import torch
import os
from diffusers.utils import load_image
from services.model_loader import get_pipeline

router = APIRouter(prefix="/editing", tags=&#91;"Image Editing"])

class EditRequest(BaseModel):
    prompt: str = Field(..., description="The editing text prompt describing the transformation.")
    input_image_path_or_url: str = Field(..., description="Local file path or URL of the source image/sketch.")
    output_path: str = Field(..., description="The target file path where the edited image will be saved.")
    width: int = Field(1024, description="Image width.")
    height: int = Field(1024, description="Image height.")
    seed: int = Field(20261009, description="Random seed for reproducibility.")

@router.post("/edit-image", status_code=status.HTTP_200_OK)
def edit_image(req: EditRequest):
    try:
        pipe = get_pipeline()

        if req.input_image_path_or_url.startswith("http://") or req.input_image_path_or_url.startswith("https://"):
            input_image = load_image(req.input_image_path_or_url).convert("RGBA")
        else:
            if not os.path.exists(req.input_image_path_or_url):
                raise HTTPException(status_code=400, detail=f"Input image not found: {req.input_image_path_or_url}")
            input_image = load_image(req.input_image_path_or_url).convert("RGBA")

        output_dir = os.path.dirname(req.output_path)
        if output_dir and not os.path.exists(output_dir):
            os.makedirs(output_dir, exist_ok=True)

        image = pipe(
            prompt=req.prompt,
            image=input_image,
            width=req.width,
            height=req.height,
            use_kv_cache=True,
            generator=torch.Generator("cpu").manual_seed(req.seed),
        ).images&#91;0]

        image.save(req.output_path)

        return {
            "status": "success",
            "message": "Image editing completed successfully.",
            "output_path": req.output_path
        }
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Image editing failed: {str(e)}")</code></pre>



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



<h2 class="wp-block-heading">Q: 為什麼執行  main.py 之後, 畫面顯示還要下載其他檔案?</h2>



<p class="wp-block-paragraph">這是因為一個完整的文生圖 Pipeline（QwenImage21Pipeline）不只有 Transformer 主模型，還包含其他必備元件：</p>



<h3 class="wp-block-heading">1. 為什麼本機有了 .gguf 還在下載？</h3>



<p class="wp-block-paragraph">在 model_loader.py:35-39 中：</p>



<pre class="wp-block-code"><code># 1. 這裡載入的是您本機已有的 Transformer 權重 (約 4GB 的 GGUF)
transformer = QwenImage21Transformer2DModel.from_single_file(GGUF_MODEL_PATH, ...)

# 2. 這裡組裝整個 Pipeline：
_pipe = QwenImage21Pipeline.from_pretrained(
    "Qwen/Qwen-Image-2.1-Turbo",
    transformer=transformer,   # &lt;--- Transformer 替換成本地的 GGUF
    torch_dtype=torch.bfloat16,
)</code></pre>



<p class="wp-block-paragraph">文生圖 Pipeline 除了 Transformer（降噪核心）之外，還需要：</p>



<ol class="wp-block-list">
<li>Text Encoder（文字編碼器 / Tokenizer）：負責把您的英文 Prompt 轉成模型看得懂的語意向量。</li>



<li>VAE（變分自編碼器）：負責將潛空間特徵解碼還原成真實的 PNG/JPEG 像素圖片。</li>



<li>Scheduler / Config 設定檔：去噪步數調度器。</li>
</ol>



<p class="wp-block-paragraph">因為您的專案目錄目前只有 Qwen/qwen-image-2.1-turbo-Q4_K_M.gguf（只有 Transformer 本身），所以 Hugging Face Diffusers 會自動去 HuggingFace 官方下載其餘的文字編碼器（Text Encoder）與 VAE。<br>──────</p>



<h3 class="wp-block-heading">2. 需要下載多少？之後每次都要下載嗎？</h3>



<p class="wp-block-paragraph">• 只需要下載這一次：下載後的檔案會保存在本機快取目錄（C:\Users\max32.cache\huggingface\hub）。下一次再啟動或呼叫 API 時，就會直接讀取快取，不會再次重複下載。<br>• 檔案大小：主要是 Text Encoder 與 VAE，下載量約數 GB。</p>



<p class="wp-block-paragraph">等待這次背景下載完畢後，模型就會自動完成組裝並產出圖片！</p>



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



<p class="wp-block-paragraph">在 Diffusers 架構中，每個 Pipeline 需要下載哪些元件是由倉庫根目錄的 model_index.json 定義的。<br>對於 Qwen/Qwen-Image-2.1-Turbo，它需要的資料夾元件如下：<br>• 官方倉庫頁面：<a href="https://huggingface.co/Qwen/Qwen-Image-2.1-Turbo/tree/main">https://huggingface.co/Qwen/Qwen-Image-2.1-Turbo/tree/main</a><br>──────</p>



<h3 class="wp-block-heading">一、您需要手動下載的檔案清單</h3>



<p class="wp-block-paragraph">因為您已經有 transformer（即您本機的 qwen-image-2.1-turbo-Q4_K_M.gguf），不需要 下載倉庫中的 transformer/ 資料夾（這部分就佔了十幾 GB）。<br>您只需要下載其餘的 4 個元件資料夾 與 根目錄設定檔：</p>



<h4 class="wp-block-heading">1. 根目錄設定檔</h4>



<p class="wp-block-paragraph">• model_index.json</p>



<h4 class="wp-block-heading">2. text_encoder/ 資料夾</h4>



<p class="wp-block-paragraph">│ 文字編碼器（Qwen3-VL 文本主幹），主要權重檔比較大：</p>



<p class="wp-block-paragraph">• config.json<br>• generation_config.json<br>• model.safetensors（或是 model.safetensors.index.json 與相關的 .safetensors 分卷）</p>



<h4 class="wp-block-heading">3. processor/ 資料夾（含 Tokenizer）</h4>



<p class="wp-block-paragraph">• chat_template.json<br>• preprocessor_config.json<br>• processor_config.json<br>• tokenizer_config.json<br>• vocab.json / merges.txt（或相關的 tokenizer 檔案）</p>



<h4 class="wp-block-heading">4. vae/ 資料夾</h4>



<p class="wp-block-paragraph">│ 負責把潛向量轉為實際圖片：<br>• config.json<br>• diffusion_pytorch_model.safetensors</p>



<h4 class="wp-block-heading">5. scheduler/ 資料夾</h4>



<p class="wp-block-paragraph">• scheduler_config.json<br>──────</p>



<h3 class="wp-block-heading">二、手動下載後如何放置與修改程式碼？</h3>



<p class="wp-block-paragraph">建議在專案中建立一個本地目錄（例如 models/Qwen-Image-2.1-Turbo），並把上述檔案依照相同結構放好：</p>



<pre class="wp-block-code"><code>C:\Users\max32\Downloads\qwen_img_fastapi\
 ├── Qwen\
 │    └── qwen-image-2.1-turbo-Q4_K_M.gguf
 └── models\
      └── Qwen-Image-2.1-Turbo\
           ├── model_index.json
           ├── text_encoder\
           ├── processor\
           ├── vae\
           └── scheduler\</code></pre>



<p class="wp-block-paragraph">然後在 model_loader.py 將 BASE_MODEL_ID 改成此本機路徑：</p>



<pre class="wp-block-code"><code>BASE_MODEL_ID = os.path.join(
    os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
    "models",
    "Qwen-Image-2.1-Turbo"
)</code></pre>



<p class="wp-block-paragraph">修改後，Pipeline 就不會連網下載任何東西，而是直接從本機硬碟快速載入！<br>──────</p>



<h3 class="wp-block-heading">三、推薦的高速下載方式（比瀏覽器手動點一個個檔案快）</h3>



<p class="wp-block-paragraph">如果您使用瀏覽器一個個點比較繁瑣，推薦使用支援多執行緒並發下載的工具：</p>



<h4 class="wp-block-heading">方式 A：使用 hf-transfer（推薦，通常可跑滿寬頻）</h4>



<p class="wp-block-paragraph">在終端機安裝並開啟加速：</p>



<pre class="wp-block-code"><code>pip install -U hf-transfer huggingface_hub
$env:HF_HUB_ENABLE_HF_TRANSFER = "1"</code></pre>



<p class="wp-block-paragraph">接著使用指令直接下載排除 transformer 以外的所有檔案至本機資料夾：</p>



<pre class="wp-block-code"><code>huggingface-cli download Qwen/Qwen-Image-2.1-Turbo --exclude "transformer/*" --local-dir models/Qwen-Image-2.1-Turbo</code></pre>



<h4 class="wp-block-heading">方式 B：使用 IDM / 多線程下載器</h4>



<p class="wp-block-paragraph">如果網路對 Hugging Face 有限制，亦可利用鏡像站（如 hf-mirror.com）：</p>



<pre class="wp-block-code"><code>$env:HF_ENDPOINT = "https://hf-mirror.com"
huggingface-cli download Qwen/Qwen-Image-2.1-Turbo --exclude "transformer/*" --local-dir models/Qwen-Image-2.1-Turbo</code></pre>



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



<p class="wp-block-paragraph">驗證與除錯用curl</p>



<p class="wp-block-paragraph">Minimal curl (omit any optional field to use defaults), linux / macOS:</p>



<pre class="wp-block-code"><code>curl -X POST "http://127.0.0.1:8000/generation/generate" \
  -H "Content-Type: application/json" \
  -d "{\"prompt\": \"a sunset over the ocean\", \"output_path\": \"output/image.png\"}"</code></pre>



<p class="wp-block-paragraph">for Windows: </p>



<pre class="wp-block-code"><code>curl -X POST "http://127.0.0.1:8000/generation/generate" ^
  -H "Content-Type: application/json" ^
  -d "{\"prompt\": \"a sunset over the ocean\", \"output_path\": \"output/image.png\"}"</code></pre>



<p class="wp-block-paragraph">With custom values:</p>



<pre class="wp-block-code"><code>curl -X POST "http://127.0.0.1:8000/generation/generate" \
  -H "Content-Type: application/json" \
  -d "{\"prompt\": \"a sunset over the ocean\", \"output_path\": \"output/image.png\", \"width\": 768,
</code></pre>



<p class="wp-block-paragraph">&#8220;height&#8221;: 1024, &#8220;seed&#8221;: 123}&#8221;</p>



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



<p class="wp-block-paragraph">執行失敗</p>



<p class="wp-block-paragraph">[W1013 21:31:02.000000000 CUDACachingAllocator.cpp:3934] memory allocation failed with OOM on device 0 while trying to allocate 33554432 bytes (free: 0, total: 6438780928).<br>[W1013 21:31:02.000000000 CUDACachingAllocator.cpp:3934] memory allocation failed with OOM on device 0 while trying to allocate 33554432 bytes (free: 0, total: 6438780928).<br>INFO: 127.0.0.1:5457 &#8211; &#8220;POST /generation/generate HTTP/1.1&#8221; 500 Internal Server Error</p>



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



<p class="wp-block-paragraph">您的 Transformer 確實已經正確載入為 Q4_K_M GGUF，沒有載入到原本數十 GB 的 Transformer。</p>



<p class="wp-block-paragraph">導致顯存不足（OOM，總共 6GB 卻嘗試分配超出顯存）的真正元兇是：<br><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f449.png" alt="👉" class="wp-smiley" style="height: 1em; max-height: 1em;" /> text_encoder（文字編碼器，即 Qwen3VL）。</p>



<ol class="wp-block-list">
<li>體積對比：<br>• 您量化後的 Transformer（qwen-image-2.1-turbo-Q4_K_M.gguf）：只有 約 3.9 GB。<br>• 官方未量化的 text_encoder/model.safetensors：高達 16.3 GB（BF16/FP16）！</li>



<li>為什麼報錯：<br>• 當呼叫 pipe(&#8230;) 生成圖片的第一步，Pipeline 會先執行 text_encoder 來解析 prompt。<br>• 雖然設定了 enable_model_cpu_offload()，但在執行 text_encoder 時，會嘗試將這 16.3 GB 的整個文字編碼器移進 GPU<br>顯存；但您的 RTX 4050 只有 6 GB 顯存，因此在瞬間爆顯存（OOM）。</li>
</ol>



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



<h3 class="wp-block-heading">解決方案</h3>



<p class="wp-block-paragraph">對於 6GB VRAM，有以下兩種解決方案：</p>



<h4 class="wp-block-heading">方案 1：啟用 enable_sequential_cpu_offload()（最直接，無需下載新模型）</h4>



<p class="wp-block-paragraph">Diffusers 提供了層級依序卸載（Sequential CPU Offload）。它不會把整個 16GB text_encoder 一次性塞入<br>GPU，而是一層一層搬進 GPU 運算後立刻移出，完美相容 6GB 甚至 4GB 顯存。<br>• 優點：馬上能跑，不需要額外下載任何量化 text_encoder。<br>• 缺點：文字編碼階段會稍慢一些。</p>



<h4 class="wp-block-heading">方案 2：對 text_encoder 使用 bitsandbytes 4-bit 量化（推薦長期使用）</h4>



<p class="wp-block-paragraph">使用 BitsAndBytesConfig(load_in_4bit=True) 載入文字編碼器，將 16GB 的 text_encoder 直接壓縮至約 4GB 甚至更小。</p>



<ol class="wp-block-list">
<li>方案 1（enable_sequential_cpu_offload）最簡單、即插即用：<br>• 它是 Diffusers 官方內建的顯存極限優化機制。<br>• 不需要安裝額外套件（目前您的環境尚未安裝 bitsandbytes，若在 Windows 安裝 bitsandbytes 常有 CUDA<br>編譯環境相容性問題）。<br>• 它會將模型逐層（Layer-by-layer）調度進 6GB 顯存，執行完立即釋放，16GB 的 text_encoder 也能順利執行，徹底解決<br>OOM。</li>



<li>兩者的關係是二選一：<br>• 方案 1 是透過「分批搬移權重」來省顯存。<br>• 方案 2 是透過「壓縮模型本體體積」來省顯存。<br>• 一般來說，直接套用方案 1 就能成功執行。</li>
</ol>



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



<p class="wp-block-paragraph">這兩次測試讓我們看清了兩個關鍵問題與癥結點：</p>



<h3 class="wp-block-heading">1. 剛剛兩次報錯的真正原因</h3>



<p class="wp-block-paragraph">• 第一次（OOM 爆顯存）：<br>您載入的確實是 -Q4_K_M.gguf（只有 3.9GB），沒有載入錯。<br>但文生圖 Pipeline 包含的文字編碼器 text_encoder/model.safetensors（Qwen3-VL-8B）是未量化的 BF16，檔案大小高達 16.3<br>GB！<br>當使用 enable_model_cpu_offload() 時，Diffusers 在處理文字時會把整個 16.3GB 的 text_encoder 載入 GPU，而您的 RTX<br>4050 只有 6GB 顯存，直接瞬間 OOM。<br>• 第二次（KeyError: None）：<br>這是 Hugging Face Diffusers 目前官方的一個已知 Bug（Issue #14691 <a href="https://github.com/huggingface/diffusers/issues%EF%BC%89%EF%BC%9A">https://github.com/huggingface/diffusers/issues）：</a><br>當使用 enable_sequential_cpu_offload() 時，Diffusers 的層級卸載機制與 GGUF 自訂參數格式不相容（在重新包裝 tensor<br>時丟失了 quant_type 屬性），導致報錯崩潰。</p>



<p class="wp-block-paragraph">此外，檢查了您的系統記憶體，電腦實體 RAM 為 12 GB（可用約 8 GB）。要把 16.3 GB 的 BF16<br>文字編碼器同時放在記憶體中本身就非常極限。<br>──────</p>



<h3 class="wp-block-heading">2. 根本解法：使用量化版 Text Encoder</h3>



<p class="wp-block-paragraph">Qwen-Image-2.1 之所以顯存要求極高，是因為它採用了 8B 級別的多模態大模型（Qwen3-VL-8B）作為文字編碼器。</p>



<p class="wp-block-paragraph">要讓 RTX 4050 (6GB VRAM) + 12GB RAM 能夠流暢生成圖片，不能使用原本 16.3GB 的文字編碼器，而必須將 text encoder<br>也換成量化版（例如 Comfy-Org 或社群提供的 FP8 或 INT8 權重）：</p>



<p class="wp-block-paragraph">• 原版 BF16 text encoder：16.3 GB <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;" />（無法塞入 6GB 顯存與 12GB RAM）<br>• FP8 / INT8 text encoder：約 4~8 GB <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/2714.png" alt="✔" class="wp-smiley" style="height: 1em; max-height: 1em;" />（可順利載入運算）<br>──────</p>



<h3 class="wp-block-heading">3. 建議下一步</h3>



<p class="wp-block-paragraph">目前最推薦的處理解法：</p>



<ol class="wp-block-list">
<li>從 Hugging Face 下載輕量化版（FP8 或 INT8，約 4~8GB）的 Qwen3-VL 文字編碼器，替換掉 models/Qwen-Image-2.1-<br>Turbo/text_encoder。</li>



<li>同時保留您現有的 qwen-image-2.1-turbo-Q4_K_M.gguf 作為 Transformer。</li>



<li>這樣兩者都能控制在 4~5 GB 內，透過 enable_model_cpu_offload() 就能完美在 RTX 4050 6GB 跑完完整圖片生成流程。</li>
</ol>



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



<p class="wp-block-paragraph">錯誤訊息</p>



<p class="wp-block-paragraph">curl -X POST &#8220;<a href="http://127.0.0.1:8000/generation/generate">http://127.0.0.1:8000/generation/generate</a>&#8221; ^<br>More? -H &#8220;Content-Type: application/json&#8221; ^<br>More? -d &#8220;{&#8220;prompt&#8221;: &#8220;a sunset over the ocean&#8221;, &#8220;output_path&#8221;: &#8220;output/image.png&#8221;}&#8221;<br><br>{&#8220;detail&#8221;:&#8221;Generation failed: The size of tensor a (4096) must match the size of tensor b (8192) at non-singleton dimension 2&#8243;}</p>



<h2 class="wp-block-heading">Root Cause: Text Encoder Size Mismatch</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Component</th><th>Hidden Size</th><th>Source</th></tr></thead><tbody><tr><td>Your text_encoder (Qwen/Qwen-Image-2.1-Turbo official)</td><td>4096 (Qwen3-VL-4B)</td><td>hidden_size: 4096 in config.json</td></tr><tr><td>Your GGUF transformer (Unsloth Q4_K_M)</td><td>expects 8192</td><td>Unsloth trained it against Qwen3-VL-8</td></tr><tr><td>transformer/config.json context_in_dim</td><td>4096 <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>matches the official 4B text encoder</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The contradiction: the official transformer/config.json says context_in_dim: 4096 (correct for 4B TE), but the<br>Unsloth GGUF you downloaded was quantized from a model that used Qwen3-VL-8B (8192) as its text encoder. So those<br>two are incompatible.<br>──────</p>



<h2 class="wp-block-heading">Should you download the Unsloth GGUF?</h2>



<p class="wp-block-paragraph">No — you don&#8217;t need to switch. The better fix is to use the official Qwen transformer weights (not Unsloth&#8217;s GGUF),<br>or use your existing GGUF correctly paired with the right text encoder.</p>



<h3 class="wp-block-heading">You have 2 paths:</h3>



<p class="wp-block-paragraph">Option A — Ditch the GGUF, load the official model fully (simplest fix)<br>Use QwenImage21Pipeline.from_pretrained with all local weights. The official models/Qwen-Image-2.1-Turbo has<br>context_in_dim=4096 matching your 4B text encoder. No GGUF at all.<br></p>



<p class="wp-block-paragraph">Option B — Keep the Unsloth GGUF, replace the text encoder with Qwen3-VL-8B<br>Download Unsloth&#8217;s recommended Qwen3-VL-8B-Instruct-UD-Q4_K_XL.gguf text encoder. But this requires significant<br>additional download (~5GB+) and more complex loading code.</p>



<p class="wp-block-paragraph"><br>──────<br>Recommendation: Option A — you already have all the official weights. Let me fix model_loader.py to load the full<br>pipeline from local files without the mismatched GGUF:<br>● Ran 1 command, checked 1 task (python -c &#8220;import diffusers; print(diffusers.<strong>version</strong>)&#8221; · tas&#8230;) (ctrl+o to expand</p>



<p class="wp-block-paragraph">Good — diffusers 0.41.0.dev0. Now let me fix the model loader to load the official pipeline without the mismatched<br>Unsloth GGUF:</p>



<p class="wp-block-paragraph">● Edited 1 file (model_loader.py) (ctrl+o to expand)</p>



<p class="wp-block-paragraph">Now restart your server and try again:</p>



<pre class="wp-block-code"><code># Kill any running server first, then:
python -m uvicorn main:app --host 127.0.0.1 --port 8000</code></pre>



<p class="wp-block-paragraph">Then test:</p>



<pre class="wp-block-code"><code>curl -X POST "http://127.0.0.1:8000/generation/generate" `
  -H "Content-Type: application/json" `
  -d "{\"prompt\": \"a sunset over the ocean\", \"output_path\": \"output/image.png\"}"
</code></pre>



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



<h2 class="wp-block-heading">Summary</h2>



<p class="wp-block-paragraph">The bug: Your Unsloth GGUF (qwen-image-2.1-turbo-Q4_K_M.gguf) was trained against the Qwen3-VL-8B text encoder<br>(hidden_size=8192), but your local text_encoder/ is Qwen3-VL-4B (hidden_size=4096). The transformer&#8217;s txt_in layer<br>tried to accept a 4096-wide tensor but had weights sized for 8192.</p>



<p class="wp-block-paragraph">The fix: Removed the GGUF entirely. The official models/Qwen-Image-2.1-Turbo has matching context_in_dim=4096 in<br>transformer/config.json — all dimensions align. The full bf16 transformer weights are in the .safetensors shards<br>already in models/Qwen-Image-2.1-Turbo/transformer/.</p>



<p class="wp-block-paragraph">Re: Unsloth download — No need to download it. The mismatch would persist unless you also replaced the text encoder<br>with their 8B GGUF version, adding ~5GB more download and significantly more complex loading code. The official<br>weights work perfectly as-is.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/10/qwen-image-2-1-turbo/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Azure 升級大作戰：告別通行金鑰，擁抱 Managed Identity</title>
		<link>https://stackoverflow.max-everyday.com/2026/09/azure-managed-identity-storage-account/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/09/azure-managed-identity-storage-account/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Mon, 21 Sep 2026 05:27:15 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[azure]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8771</guid>

					<description><![CDATA[這是一篇教大家如何把 Azure Storage...]]></description>
										<content:encoded><![CDATA[
<figure class="wp-block-image size-large"><img decoding="async" width="1024" height="572" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_12799267848597312935_clean-1024x572.jpg?v=1789968401" alt="" class="wp-image-8772" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_12799267848597312935_clean-1024x572.jpg?v=1789968401 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_12799267848597312935_clean-767x428.jpg?v=1789968401 767w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_12799267848597312935_clean-600x335.jpg?v=1789968401 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_12799267848597312935_clean.jpg?v=1789968401 1376w" sizes="(max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">這是一篇教大家如何把 Azure Storage 從舊時代的金鑰存取，安全升級到託管身分 Managed Identity 的技術指南。</p>



<p class="wp-block-paragraph">想像一下，你家大門的鑰匙就被你大大方方放在門口的鞋櫃上，任何走過路過的人只要拿到這把鑰匙就能直接進你家搬東西。這就是以前我們使用存取金鑰 Connection String 連線到 Azure Storage 的驚險狀況！</p>



<p class="wp-block-paragraph">為了不讓駭客當你家是花園，我們強烈建議改用託管身分 Managed Identity。</p>



<p class="wp-block-paragraph">不過設定上會和 SQL 連線不太一樣。SQL 只要在連線字串加個 useMsi=true 就能解決，但 Blob Storage 必須搭配 Blob SDK 使用 Entra ID Token，並且在 Azure RBAC 權限控管中，給予你的 Container App 相應的權限。</p>



<p class="wp-block-paragraph">在目前的測試環境中，我們的後端 App 雖然有了系統分配的身分，但只有拿到拉取映像檔的 AcrPull 權限。至於檔案儲存區，依然把金鑰 AZURE_STORAGE_CONNECTION_STRING 硬塞給後端程式碼。</p>



<p class="wp-block-paragraph">為了達成最高等級的資安防護，以下是推薦的升級改造方向：</p>



<h4 class="wp-block-heading">1. 打造安全的基礎架構</h4>



<ul class="wp-block-list">
<li>私有化儲存區：Blob Container 絕對不能開啟任何人都能看的公開存取存取權限。</li>



<li>後端專屬通道：只有後端 App 可以接觸儲存區，前端完全不拿任何金鑰或 Token。</li>



<li>權限最小化原則：給後端 App 的身分授權 Storage Blob Data Contributor 角色，而且範圍只能限定在 demoproject-attachments 這個容器。這樣它就能讀、寫、刪除檔案。（千萬別偷懶給了一般權限，那樣權限太大了！）</li>
</ul>



<h4 class="wp-block-heading">2. 後端 Go 程式碼改版</h4>



<p class="wp-block-paragraph">後端改用 DefaultAzureCredential 搭配 Storage Account 的 Blob 端點。這樣做的好處是，你在本機開發時會自動用你電腦 az login 的身分；一旦部署到 Azure Container Apps 時，就會自動無縫切換成 Managed Identity，完全不用改程式碼！</p>



<p class="wp-block-paragraph">你的專案已經有 azidentity 套件，不需要另外安裝。請把 config.go 中的連線字串改為帳號名稱 AccountName，並修改 internal/storage/blob.go：</p>



<pre class="wp-block-code"><code>// internal/storage/blob.go
import "github.com/Azure/azure-sdk-for-go/sdk/azidentity"

func NewBlobClient(accountName, containerName string) (*BlobClient, error) {
    if accountName == "" {
        return nil, nil // 如果本地環境停用附件功能就跳過
    }
    if containerName == "" {
        return nil, fmt.Errorf("azure storage container name is required")
    }

    credential, err := azidentity.NewDefaultAzureCredential(nil)
    if err != nil {
        return nil, fmt.Errorf("create azure credential: %w", err)
    }

    serviceURL := fmt.Sprintf("https://%s.blob.core.windows.net/", accountName)
    client, err := azblob.NewClient(serviceURL, credential, nil)
    if err != nil {
        return nil, fmt.Errorf("create blob client: %w", err)
    }

    return &amp;BlobClient{client: client, container: containerName}, nil
}
</code></pre>



<p class="wp-block-paragraph"><em>(別忘了主程式 main.go 也要改為呼叫 NewBlobClient(cfg.AzureStorage.AccountName, cfg.AzureStorage.ContainerName) )</em></p>



<h4 class="wp-block-heading">3. 自動化部署腳本更新</h4>



<p class="wp-block-paragraph">部署時不再傳送一長串的金鑰，改為傳送儲存區名稱：</p>



<pre class="wp-block-code"><code>$STORAGE_ACCOUNT_NAME = "demostorageaccount"
$AZURE_STORAGE_CONTAINER_NAME = "demoproject-attachments"
</code></pre>



<p class="wp-block-paragraph">在取得後端的服務身分 ID 後，加入 RBAC 授權：</p>



<pre class="wp-block-code"><code>$storageAccountId = az storage account show `
  --name $STORAGE_ACCOUNT_NAME `
  --resource-group $STORAGE_RG `
  --query id -o tsv
Assert-Success "storage account show"

$storageContainerScope = "$storageAccountId/blobServices/default/containers/$AZURE_STORAGE_CONTAINER_NAME"

az role assignment create `
  --assignee-object-id $backendPrincipalId `
  --assignee-principal-type ServicePrincipal `
  --role "Storage Blob Data Contributor" `
  --scope $storageContainerScope | Out-Null
Assert-Success "Storage Blob Data Contributor role"
</code></pre>



<p class="wp-block-paragraph">最後更新環境變數設定：</p>



<pre class="wp-block-code"><code>@{ name = "AZURE_STORAGE_ACCOUNT_NAME";   value = $STORAGE_ACCOUNT_NAME }
@{ name = "AZURE_STORAGE_CONTAINER_NAME"; value = $AZURE_STORAGE_CONTAINER_NAME }
</code></pre>



<p class="wp-block-paragraph"><em>(小提醒：正式環境的 deploy-production.ps1、.env.example 等檔案也要同步更新，不然正式環境還會傻傻地跟你要金鑰喔！)</em></p>



<h4 class="wp-block-heading">4. 警告！附件下載流程會壞掉！</h4>



<p class="wp-block-paragraph">這是大家升級時最容易踩到的超大陷阱！</p>



<p class="wp-block-paragraph">Managed Identity 的通行證只有「後端 到 儲存區」有效。一旦我們把儲存區設為私有，以前那種直接把網址丟給前端網頁點擊下載的做法就會直接失效，使用者點開只會看到冷酷的 403 Forbidden 拒絕存取畫面！</p>



<p class="wp-block-paragraph"><strong>該怎麼拯救下載功能？</strong></p>



<p class="wp-block-paragraph">千萬不要因為下載壞掉就把儲存區重新開放給外面的所有人看！</p>



<p class="wp-block-paragraph">正確的解法是讓後端當「轉接員」，新增一個需要驗證的下載 API 路線：</p>



<p class="wp-block-paragraph">GET /api/announcements/{id}/attachments/{attachmentId}/download</p>



<p class="wp-block-paragraph">流程變成：前端跟後端要檔案，後端憑著自己的 Managed Identity 身份去把檔案拿回來，再傳給使用者。這樣既安全又不會斷功能！</p>



<h4 class="wp-block-heading">改造總結步驟</h4>



<ol start="1" class="wp-block-list">
<li>建立專屬的容器 Container。</li>



<li>設定後端身分的 RBAC 授權。</li>



<li>修改 Go 語言程式碼改走 DefaultAzureCredential。</li>



<li>更新部署時的環境變數。</li>



<li>把下載機制改為後端轉接模式（最重要的一步）。</li>



<li>開始歡樂測試上傳、下載、刪除，確保一切正常！</li>
</ol>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/09/azure-managed-identity-storage-account/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>在 Azure Container Apps 建立隔離的 Demo MSSQL 資料庫</title>
		<link>https://stackoverflow.max-everyday.com/2026/09/azure-container-apps-demo-mssql/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/09/azure-container-apps-demo-mssql/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Mon, 14 Sep 2026 07:31:58 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[azure]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8767</guid>

					<description><![CDATA[Demo 資料說明 本文是一篇教學用範例。Exa...]]></description>
										<content:encoded><![CDATA[
<figure class="wp-block-image size-large"><img decoding="async" width="1024" height="572" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_11674646855586028103_clean-1024x572.jpg?v=1789371090" alt="" class="wp-image-8768" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_11674646855586028103_clean-1024x572.jpg?v=1789371090 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_11674646855586028103_clean-600x335.jpg?v=1789371090 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_11674646855586028103_clean-767x428.jpg?v=1789371090 767w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_11674646855586028103_clean.jpg?v=1789371090 1376w" sizes="(max-width: 1024px) 100vw, 1024px" /></figure>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>Demo 資料說明</strong></p>



<p class="wp-block-paragraph">本文是一篇教學用範例。Example Company、<code>demoproject</code>、Azure 資源名稱、帳號、網域與 tenant 都是虛構資料，不能直接拿來連線或部署。實際使用時，請替換成自己的環境設定。</p>
</blockquote>



<h2 class="wp-block-heading">先說結論</h2>



<p class="wp-block-paragraph">Example Company 的開發團隊想在 Azure Container Apps 裡快速展示 Backend 功能，但暫時不需要使用既有的 Azure SQL Database，也不需要讀取既有的 <code>demoproject-dev</code> 資料。</p>



<p class="wp-block-paragraph">這種情況可以在同一個 Container Apps Environment 裡，另外部署一個暫時的 Microsoft SQL Server container，提供一個全新的 Demo database。</p>



<p class="wp-block-paragraph">這個方案的重點是：</p>



<ul class="wp-block-list">
<li>Demo SQL Server 是獨立的新資料庫。</li>



<li>Backend 不連既有的 <code>demoproject-dev</code>。</li>



<li>不匯入正式或 staging 資料。</li>



<li>不使用既有 Azure SQL 的 Managed Identity、contained user 或 database roles。</li>



<li>Backend、MSSQL 和 Frontend 仍然要正確設定網路、secret、storage、health check 和 CI/CD。</li>
</ul>



<p class="wp-block-paragraph">換句話說，這不是把原本的 Azure SQL 搬進 container，而是建立一套<strong>隔離、短期、只供 Demo 使用的資料庫環境</strong>。</p>



<h2 class="wp-block-heading">為什麼需要這個方案？</h2>



<p class="wp-block-paragraph">假設目前的開發環境是：</p>



<pre class="wp-block-code"><code>cae-example-dev-001</code></pre>



<p class="wp-block-paragraph">這個 Environment 主要用來執行開發中的 Container App，但沒有連到既有 Azure SQL Private Endpoint 所需的完整 VNet 路徑。</p>



<p class="wp-block-paragraph">如果 Backend 直接使用 Azure SQL，可能會遇到：</p>



<ul class="wp-block-list">
<li>SQL network connection timeout。</li>



<li>Private Endpoint 無法連線。</li>



<li>Azure SQL firewall 拒絕來源 IP。</li>



<li>Managed Identity 可以取得 token，但 SQL database 沒有對應的 user 或 roles。</li>
</ul>



<p class="wp-block-paragraph">可是，這次 Demo 的需求並不是要存取既有資料，而是希望先讓團隊能夠：</p>



<ol class="wp-block-list">
<li>啟動 Backend API。</li>



<li>建立一些測試資料。</li>



<li>展示查詢、新增、修改等基本功能。</li>



<li>讓 Frontend 能呼叫 Backend。</li>
</ol>



<p class="wp-block-paragraph">因此，可以把資料庫改成和 Backend 位於同一個 Container Apps Environment 的內部 MSSQL App，讓這次 Demo 不依賴既有 Azure SQL 的 Private Endpoint。</p>



<h2 class="wp-block-heading">目標架構</h2>



<p class="wp-block-paragraph">Demo 環境可以設計成下面這樣：</p>



<pre class="wp-block-code"><code>Frontend 或測試工具
          │
          ▼
ca-example-backend-dev-001
          │ internal connection
          ▼
ca-example-mssql-demo-dev-001
          │ TCP 1433，僅限 internal ingress
          ▼
demoproject-demo</code></pre>



<p class="wp-block-paragraph">示範資源如下：</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>元件</th><th>Demo 名稱</th><th>用途</th></tr></thead><tbody><tr><td>Container Apps Environment</td><td><code>cae-example-dev-001</code></td><td>執行所有 Demo App</td></tr><tr><td>Backend App</td><td><code>ca-example-backend-dev-001</code></td><td>提供 API</td></tr><tr><td>MSSQL App</td><td><code>ca-example-mssql-demo-dev-001</code></td><td>提供 Demo database</td></tr><tr><td>Database</td><td><code>demoproject-demo</code></td><td>全新、隔離的測試資料</td></tr><tr><td>Resource Group</td><td><code>rg-demo-dev-001</code></td><td>存放 Demo Container Apps</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">MSSQL 應該是獨立的 Container App，不要和 Backend 放在同一個 container。這樣可以分別更新 Backend 和資料庫，也比較容易在 Demo 結束時清理。</p>



<h2 class="wp-block-heading">需要怎麼做？</h2>



<h3 class="wp-block-heading">第一步：建立獨立的 MSSQL Container App</h3>



<p class="wp-block-paragraph">使用官方 Microsoft SQL Server Linux container image，並固定版本或 digest，不要長期使用會隨時間變動的 <code>latest</code> tag。</p>



<p class="wp-block-paragraph">MSSQL App 的基本設定應包含：</p>



<pre class="wp-block-code"><code>App name:    ca-example-mssql-demo-dev-001
Environment: cae-example-dev-001
Port:        TCP 1433
Ingress:     internal only
Database:    demoproject-demo</code></pre>



<p class="wp-block-paragraph">Container 需要設定：</p>



<ul class="wp-block-list">
<li><code>ACCEPT_EULA=Y</code></li>



<li><code>MSSQL_PID=Developer</code></li>



<li><code>MSSQL_SA_PASSWORD</code></li>



<li>足夠的 CPU 和 memory</li>



<li>TCP <code>1433</code> target port</li>



<li>readiness/startup health check</li>
</ul>



<p class="wp-block-paragraph">SQL Server container 通常至少需要 2 GiB memory；Demo 建議依實際啟動時間與資料量配置更多資源。</p>



<p class="wp-block-paragraph"><code>MSSQL_PID=Developer</code> 只適合非正式 Demo，不能當成 production license 或正式 staging database。SA password 不可以寫在 Dockerfile、image、repository 或 workflow 明文中。</p>



<h3 class="wp-block-heading">第二步：只開放內部網路</h3>



<p class="wp-block-paragraph">MSSQL App 只能使用 internal ingress：</p>



<pre class="wp-block-code"><code>Backend App ── internal DNS ──&gt; MSSQL App:1433</code></pre>



<p class="wp-block-paragraph">不要把 SQL Server 的 TCP <code>1433</code> 開成 public ingress，也不要讓測試人員直接從 Internet 連入。</p>



<p class="wp-block-paragraph">Backend 和 MSSQL App 最簡單的做法是放在同一個 <code>cae-example-dev-001</code>。Backend 使用 MSSQL App 的 internal FQDN 連線，不要自行猜測 FQDN 格式，應從 Azure Container Apps 的 App 設定或 DNS 設定取得實際值。</p>



<h3 class="wp-block-heading">第三步：建立專用 application login</h3>



<p class="wp-block-paragraph">Backend 不應使用 <code>sa</code> 登入。MSSQL container 第一次啟動後，應執行一次初始化流程：</p>



<ol class="wp-block-list">
<li>建立 <code>demoproject-demo</code> database。</li>



<li>建立專用的 Backend application login。</li>



<li>只授予 application login 必要的 read/write 權限。</li>



<li>只有需要執行 schema migration 時，才授予有限的 DDL 權限。</li>



<li>確認 Backend 不會取得 SA password。</li>
</ol>



<p class="wp-block-paragraph">這個 Demo SQL Server 是獨立的 SQL container，Backend 通常會使用 SQL login/password，而不是既有 Azure SQL 的 Microsoft Entra Managed Identity flow。</p>



<p class="wp-block-paragraph">因此，application login 的 username 和 password 必須放在 Container App secret 或 Key Vault secret reference 中，不能放在 source code 或公開設定檔。</p>



<h3 class="wp-block-heading">第四步：修改 Backend 的 connection string</h3>



<p class="wp-block-paragraph">Backend 原本如果使用 Azure SQL Managed Identity，可能會包含這類設定：</p>



<pre class="wp-block-code"><code>fedauth=ActiveDirectoryManagedIdentity
useMsi=true</code></pre>



<p class="wp-block-paragraph">切換到 Demo MSSQL 後，Backend 必須改成：</p>



<ul class="wp-block-list">
<li>SQL host：<code>ca-example-mssql-demo-dev-001</code> 的 internal FQDN。</li>



<li>Port：<code>1433</code>。</li>



<li>Database：<code>demoproject-demo</code>。</li>



<li>Username：專用 application login。</li>



<li>Password：由 secret reference 注入。</li>



<li>Authentication：使用 SQL login/password。</li>
</ul>



<p class="wp-block-paragraph">概念上的設定如下：</p>



<pre class="wp-block-code"><code>sqlserver://&lt;mssql-internal-host&gt;:1433
  ?database=demoproject-demo
  &amp;user=&lt;application-user&gt;
  &amp;password=&lt;secret-reference&gt;</code></pre>



<p class="wp-block-paragraph">實際格式要依 Backend 使用的 SQL driver 調整。不要把完整 connection string 寫進 GitHub Actions log、Dockerfile 或公開的 frontend configuration。</p>



<p class="wp-block-paragraph">如果 Demo SQL Server 使用 self-signed certificate，請明確決定測試環境的 TLS 設定。即使是 Demo，也不要把關閉加密或無條件信任憑證當成正式環境的解法。</p>



<h3 class="wp-block-heading">第五步：建立 schema 和測試資料</h3>



<p class="wp-block-paragraph">全新的 <code>demoproject-demo</code> 不會自動有任何 table，因此需要另外準備：</p>



<ul class="wp-block-list">
<li>schema migration。</li>



<li>初始化 seed data。</li>



<li>Demo 使用者和測試帳號。</li>



<li>API 所需的 reference data。</li>



<li>migration 失敗時的處理方式。</li>
</ul>



<p class="wp-block-paragraph">建議使用一次性的 bootstrap 或 job 初始化 database，不要讓每一個 Backend revision 在啟動時同時執行完整 migration，否則可能發生 migration race condition。</p>



<p class="wp-block-paragraph">Demo data 應該是新建或去識別化資料，不要將正式資料直接複製到這個 container。</p>



<h3 class="wp-block-heading">第六步：處理資料持久化</h3>



<p class="wp-block-paragraph">Container App 的 local filesystem 不適合當成可靠的 database storage。如果沒有持久化 volume，以下情況都可能讓資料消失：</p>



<ul class="wp-block-list">
<li>Container restart。</li>



<li>Revision replacement。</li>



<li>App 被重新建立。</li>



<li>Node 或 host 發生故障。</li>
</ul>



<p class="wp-block-paragraph">如果 Demo 只做一次性展示，可以接受資料在環境重建後消失；如果需要保留測試結果，至少要：</p>



<ul class="wp-block-list">
<li>將 SQL Server data/log 目錄掛到持久化 storage，例如 Azure Files。</li>



<li>驗證 SQL Server 在該 storage 上的 lock、I/O 和 recovery 行為。</li>



<li>設定 backup，並將 backup 放到獨立的儲存位置。</li>



<li>訂定資料保存期限。</li>



<li>確認刪除 App 或 volume 時，不會誤刪唯一一份資料。</li>
</ul>



<p class="wp-block-paragraph">即使使用 Azure Files，這仍然不等同於 Azure SQL Database 的 HA、backup、patching、SLA 和資料庫服務保證，所以必須把它標示為 <code>temporary demo database</code>。</p>



<h3 class="wp-block-heading">第七步：把資料庫 bootstrap 和一般 CI/CD 分開</h3>



<p class="wp-block-paragraph">MSSQL App 不應該在每次 Backend push 時被刪除或重建。建議把流程分成兩部分。</p>



<p class="wp-block-paragraph"><strong>一次性的 Demo bootstrap：</strong></p>



<ul class="wp-block-list">
<li>建立或更新 <code>ca-example-mssql-demo-dev-001</code>。</li>



<li>設定 image、secret、storage、internal ingress 和 health check。</li>



<li>等待 SQL Server ready。</li>



<li>建立 <code>demoproject-demo</code>、application login、schema 和 seed data。</li>
</ul>



<p class="wp-block-paragraph"><strong>一般 Backend CI/CD：</strong></p>



<ul class="wp-block-list">
<li>編譯 Backend。</li>



<li>Push Backend image 到 ACR。</li>



<li>更新 <code>ca-example-backend-dev-001</code>。</li>



<li>保留 MSSQL App 和 volume，不要重新初始化 database。</li>
</ul>



<p class="wp-block-paragraph">Backend runtime 仍然需要自己的 ACR <code>AcrPull</code>。GitHub OIDC Service Principal 只負責 CI/CD 所需的 <code>AcrPush</code> 和 App deployment 權限，不需要 SQL database role。</p>



<p class="wp-block-paragraph">如果 MSSQL image 直接從 MCR pull，必須確認 Environment 可以連到 MCR。如果團隊將 MSSQL image mirror 到 ACR，則 MSSQL App 的 runtime identity 也要另外授予 ACR <code>AcrPull</code>。</p>



<h3 class="wp-block-heading">第八步：處理啟動順序和健康檢查</h3>



<p class="wp-block-paragraph">SQL Server container 通常比 API container 更久才會 ready，因此 Backend 不能假設 SQL 在啟動第一秒就能連線。</p>



<p class="wp-block-paragraph">需要加入：</p>



<ul class="wp-block-list">
<li>MSSQL readiness/health check。</li>



<li>Backend 的 connection retry 和 backoff。</li>



<li>將「SQL 尚未 ready」與「帳號密碼錯誤」分開記錄。</li>



<li>SQL container restart 後，Backend 自動恢復連線。</li>



<li>避免在 logs 印出 password、完整 connection string 或 token。</li>
</ul>



<p class="wp-block-paragraph"><code>az containerapp update</code> 成功只代表 Azure 接受了 App 設定，不代表 Backend 一定已經能連到 MSSQL。必須等兩個 App 都 healthy 後，再測試 API。</p>



<h2 class="wp-block-heading">Frontend 要調整什麼？</h2>



<p class="wp-block-paragraph">Frontend 不需要 SQL 權限，也不應取得 Backend 的 SQL credentials。</p>



<p class="wp-block-paragraph">Frontend 需要確認：</p>



<ul class="wp-block-list">
<li>使用正確的 Frontend image。</li>



<li>使用新的 Backend API URL。</li>



<li>Nginx 或其他 web server 沒有寫死舊的 Backend hostname。</li>



<li>只有必要的 frontend secrets 被注入。</li>
</ul>



<p class="wp-block-paragraph">Frontend 只呼叫 Backend API；SQL connection 由 Backend 負責。</p>



<h2 class="wp-block-heading">安全設定</h2>



<p class="wp-block-paragraph">這個 Demo 方案至少要符合以下原則：</p>



<ul class="wp-block-list">
<li>MSSQL App 只有 internal ingress。</li>



<li>不對 Internet 公開 TCP <code>1433</code>。</li>



<li>SA password 和 application password 都放在 secret。</li>



<li>Backend 使用專用 application login，不使用 SA。</li>



<li>不把 SQL credentials 寫入 Git repository。</li>



<li>不讓 Frontend 取得 SQL credentials。</li>



<li>監控 container restart、CPU、memory、storage 和 backup。</li>



<li>為 Demo 設定 owner、用途和到期日。</li>



<li>Demo 結束後移除 App、secret、volume 和 backup。</li>
</ul>



<h2 class="wp-block-heading">驗證清單</h2>



<p class="wp-block-paragraph">部署完成後，依照以下順序驗證：</p>



<ol class="wp-block-list">
<li>MSSQL App revision 是 <code>Healthy / Running</code>。</li>



<li>Backend 可以解析 MSSQL App 的 internal FQDN。</li>



<li>Backend 可以建立 TCP <code>1433</code> connection。</li>



<li><code>demoproject-demo</code> database 存在。</li>



<li>Backend application login 可以登入。</li>



<li>schema migration 和 seed data 成功。</li>



<li>Backend API 可以正常讀寫 Demo database。</li>



<li>Backend 重啟後仍然可以重新連線。</li>



<li>MSSQL container 重啟後，volume 和 database recovery 正常。</li>



<li>Frontend 使用正確的 Backend URL。</li>



<li>沒有任何元件對 Internet 公開 SQL <code>1433</code>。</li>
</ol>



<h2 class="wp-block-heading">這個方案解決什麼、不解決什麼？</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>問題</th><th>隔離 Demo MSSQL 方案</th></tr></thead><tbody><tr><td>需要一個全新的短期測試 database</td><td>可以解決</td></tr><tr><td>不想依賴既有 Azure SQL Private Endpoint</td><td>可以避開這個依賴</td></tr><tr><td>必須使用既有 <code>demoproject-dev</code> 資料</td><td>不適用</td></tr><tr><td>需要 Azure SQL Managed Identity authentication</td><td>不會自動保留，通常要改成 SQL login</td></tr><tr><td>需要正式資料庫的 HA、backup、SLA</td><td>不適用</td></tr><tr><td>Frontend 是否需要 SQL 權限</td><td>仍然不需要</td></tr></tbody></table></figure>



<h2 class="wp-block-heading">Demo 結束後</h2>



<p class="wp-block-paragraph">這個方案應該被明確記錄為：</p>



<pre class="wp-block-code"><code>temporary isolated demo database</code></pre>



<p class="wp-block-paragraph">Demo 結束時，依序完成：</p>



<ol class="wp-block-list">
<li>匯出真正需要保留的 Demo 結果。</li>



<li>停止不再使用的 Backend 和 Frontend revision。</li>



<li>移除 SQL secrets 和 application credentials。</li>



<li>備份或刪除 Demo database volume。</li>



<li>刪除 MSSQL Container App。</li>



<li>清理 ACR 中不再使用的 Demo image。</li>



<li>確認後續部署不會誤連到 <code>demoproject-demo</code>。</li>
</ol>



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



<p class="wp-block-paragraph">在 <code>cae-example-dev-001</code> 自架 MSSQL container，可以讓 Example Company 快速得到一個不依賴既有 Azure SQL 的隔離 Demo database。這個方法適合短期展示和功能驗證，但不是把正式資料庫搬進 Container App，也不是正式 production database 的替代品。</p>



<p class="wp-block-paragraph">真正需要完成的工作不只是部署一個 MSSQL image，還包括：</p>



<ul class="wp-block-list">
<li>建立獨立的 MSSQL App。</li>



<li>只開放 internal TCP <code>1433</code>。</li>



<li>建立專用 application login。</li>



<li>將 Backend connection string 改成新的 Demo database。</li>



<li>初始化 schema 和 seed data。</li>



<li>決定是否需要持久化 storage 和 backup。</li>



<li>將 database bootstrap 與一般 Backend CI/CD 分開。</li>



<li>加入 health check、connection retry 和 logs 保護。</li>



<li>在 Demo 結束後完整清理資源。</li>
</ul>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/09/azure-container-apps-demo-mssql/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Azure Container Apps CI/CD 部署與權限配置</title>
		<link>https://stackoverflow.max-everyday.com/2026/09/azure-container-apps-ci-cd/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/09/azure-container-apps-ci-cd/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Mon, 14 Sep 2026 06:51:07 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[azure]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8762</guid>

					<description><![CDATA[Demo 資料說明 這是一份可以單獨閱讀的教學用...]]></description>
										<content:encoded><![CDATA[
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="626" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_15779193270832496688_clean-1024x626.jpg?v=1789368643" alt="" class="wp-image-8763" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_15779193270832496688_clean-1024x626.jpg?v=1789368643 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_15779193270832496688_clean-600x367.jpg?v=1789368643 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_15779193270832496688_clean-767x469.jpg?v=1789368643 767w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_15779193270832496688_clean.jpg?v=1789368643 1308w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



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



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>Demo 資料說明</strong></p>



<p class="wp-block-paragraph">這是一份可以單獨閱讀的教學用範例。所有專案名稱、公司名稱、GitHub repository、Azure 資源名稱、網域、帳號與 tenant 名稱都是虛構資料，不能直接拿來連線或部署。請依照實際環境替換成自己的設定。</p>
</blockquote>



<h2 class="wp-block-heading">這份文件要解決什麼問題</h2>



<p class="wp-block-paragraph">Example Company 的開發團隊希望使用 GitHub Actions 做 Azure CI/CD，流程如下：</p>



<ol class="wp-block-list">
<li>GitHub Actions 將程式編譯成 container image。</li>



<li>將 image push 到 Azure Container Registry（ACR）。</li>



<li>更新 Azure Container App，讓 App 使用新的 image。</li>



<li>Backend 啟動後，透過 Microsoft Entra Managed Identity 連線到 Azure SQL Database。</li>
</ol>



<p class="wp-block-paragraph">目前遇到的問題通常會長這樣：</p>



<ul class="wp-block-list">
<li>GitHub Actions 可以登入 Azure，但 Container App 啟動時無法從 ACR 拉 image。</li>



<li>Container App 的部署指令顯示成功，但新的 revision 變成 <code>ActivationFailed</code>。</li>



<li>Backend image 可以啟動，卻因為沒有 SQL contained user 或 database role 而無法登入資料庫。</li>



<li>App 位於沒有 VNet 路徑的 Container Apps Environment，因此連不到 SQL Private Endpoint。</li>



<li>Frontend 還在使用舊的 Harbor image 或舊的 Backend hostname。</li>



<li>舊的部署系統與 Azure GitHub Actions 同時執行，造成兩邊互相覆蓋部署結果。</li>
</ul>



<p class="wp-block-paragraph">這些現象看起來都像「權限問題」，但實際上分屬四個不同層次：</p>



<ol class="wp-block-list">
<li>GitHub Actions 的 CI 身分能不能登入 Azure、push image、更新 App。</li>



<li>Container App 執行時的 Managed Identity 能不能從 ACR pull image。</li>



<li>Backend 的 Managed Identity 能不能登入 SQL Database。</li>



<li>Container Apps Environment 是否有通往 SQL Private Endpoint 的網路路徑。</li>
</ol>



<h2 class="wp-block-heading">Demo 情境與資源</h2>



<p class="wp-block-paragraph">以下名稱全部是示意資料：</p>



<ul class="wp-block-list">
<li>公司：<strong>Example Company</strong></li>



<li>GitHub organization/repository：<code>example-company/demo-backend</code></li>



<li>Subscription：<code>Demo-Subscription</code></li>



<li>App/ACR Resource Group：<code>rg-demo-stg-001</code></li>



<li>共用 Container Apps Environment：<code>cae-demo-stg-001</code></li>



<li>共用 CAE 所在 Resource Group：<code>rg-cae-demo-stg-001</code></li>



<li>ACR：<code>acrdemostg001.azurecr.io</code></li>



<li>SQL Server：<code>sql-demo-stg-001.database.windows.net</code></li>



<li>SQL Database：<code>demoproject-dev</code></li>



<li>SQL Private Endpoint：<code>pe-sql-demo-stg-001</code></li>



<li>VNet：<code>vnet-demo-stg-001</code></li>
</ul>



<p class="wp-block-paragraph">預期使用的兩個 Container App 是：</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>App</th><th>用途</th><th>Environment</th></tr></thead><tbody><tr><td><code>ca-demo-backend-dev-001</code></td><td>Backend API</td><td><code>cae-demo-stg-001</code></td></tr><tr><td><code>ca-demo-frontend-dev-001</code></td><td>Frontend</td><td><code>cae-demo-stg-001</code></td></tr></tbody></table></figure>



<p class="wp-block-paragraph">另外，情境中有一組不建議繼續使用的舊 App：</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>App</th><th>舊 Environment</th><th>舊 image registry</th><th>問題</th></tr></thead><tbody><tr><td><code>ca-example-backend-dev-001</code></td><td><code>cae-example-dev-001</code></td><td><code>harbor.example.invalid</code></td><td>不在共用 CAE，也沒有正確接上 Azure ACR</td></tr><tr><td>舊 Frontend App</td><td><code>cae-example-dev-001</code></td><td>舊 Harbor</td><td>image 或 Nginx 設定可能仍指向舊 Backend</td></tr></tbody></table></figure>



<h2 class="wp-block-heading">正確的目標架構</h2>



<p class="wp-block-paragraph">完整的部署與連線路徑應該是：</p>



<pre class="wp-block-code"><code>GitHub Actions
    │ OIDC，取得短期 token
    ▼
GitHub CI Service Principal
    │ AcrPush + Container App update
    ▼
Azure Container Registry
    │ image push
    ▼
Container App（共用 cae-demo-stg-001）
    │ App 自己的 system-assigned Managed Identity
    ├─ ACR AcrPull
    ├─ Key Vault secret get/list（如果 App 使用 Key Vault）
    └─ Azure SQL Entra token
          │
          ▼
    VNet + Private DNS + SQL Private Endpoint
          │
          ▼
    demoproject-dev</code></pre>



<p class="wp-block-paragraph">最重要的觀念是：</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">GitHub Actions 的 Service Principal 負責部署，不是執行中的 App。<br>真正需要 pull image 和連 SQL 的，是各個 Container App 自己的 Managed Identity。</p>
</blockquote>



<h2 class="wp-block-heading">CI/CD 的正確流程</h2>



<h3 class="wp-block-heading">1. GitHub Actions 使用 OIDC 登入 Azure</h3>



<p class="wp-block-paragraph">GitHub Actions 不應把長期的 Azure client secret 放在 repository。建議使用 OIDC：</p>



<pre class="wp-block-code"><code>- name: Login to Azure
  uses: azure/login@v2
  with:
    client-id: ${{ vars.AZURE_CLIENT_ID }}
    tenant-id: ${{ vars.AZURE_TENANT_ID }}
    subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}</code></pre>



<p class="wp-block-paragraph">示範用的 Federated Credential subject 是：</p>



<pre class="wp-block-code"><code>repo:example-company/demo-backend:ref:refs/heads/main</code></pre>



<p class="wp-block-paragraph">如果 workflow 使用 GitHub Environment，例如 <code>staging</code>，subject 和 variables 的 scope 都要跟 GitHub Environment 的設定一致，不能只改其中一邊。</p>



<h3 class="wp-block-heading">2. CI 身分將 image push 到 ACR</h3>



<p class="wp-block-paragraph">示範用的 image repository 是：</p>



<pre class="wp-block-code"><code>acrdemostg001.azurecr.io/demo-backend:&lt;github.sha&gt;</code></pre>



<p class="wp-block-paragraph">GitHub CI Service Principal 需要 <code>AcrPush</code>，但不需要 runtime <code>AcrPull</code>。</p>



<pre class="wp-block-code"><code>AcrPush = GitHub Actions 將 image 推進 ACR
AcrPull = Container App 啟動時從 ACR 拉出 image</code></pre>



<p class="wp-block-paragraph"><code>demo-backend</code> 在第一次成功 <code>docker push</code> 前可能還不會出現在 ACR repository list 中。ACR 通常會在第一次 push 時建立 repository，這不是權限錯誤。</p>



<h3 class="wp-block-heading">3. CI 身分更新正確的 Container App</h3>



<p class="wp-block-paragraph">部署 Backend 時，target 應該是位於共用 CAE 的 App：</p>



<pre class="wp-block-code"><code>az containerapp update \
  --resource-group rg-demo-stg-001 \
  --name ca-demo-backend-dev-001 \
  --image acrdemostg001.azurecr.io/demo-backend:${GITHUB_SHA}</code></pre>



<p class="wp-block-paragraph">這個指令成功只代表 Azure 接受了 App 設定更新，不代表新的 revision 一定能啟動。接下來還要檢查 runtime identity 是否能 pull image。</p>



<h3 class="wp-block-heading">4. Container App 使用自己的 Managed Identity pull image</h3>



<p class="wp-block-paragraph">Backend 和 Frontend 都要各自完成以下設定：</p>



<ul class="wp-block-list">
<li>啟用 system-assigned Managed Identity。</li>



<li>將該 App 的 identity 在 ACR scope 授予 <code>AcrPull</code>。</li>



<li>將 Container App registry authentication 設為 <code>identity: system</code>。</li>
</ul>



<p class="wp-block-paragraph">可用以下指令設定 registry identity：</p>



<pre class="wp-block-code"><code>az containerapp registry set \
  --resource-group rg-demo-stg-001 \
  --name ca-demo-backend-dev-001 \
  --server acrdemostg001.azurecr.io \
  --identity system</code></pre>



<p class="wp-block-paragraph">Frontend App 也要使用相同方式設定，只是 <code>--name</code> 改為 <code>ca-demo-frontend-dev-001</code>。</p>



<p class="wp-block-paragraph">檢查 App identity、Environment、registry 與 image：</p>



<pre class="wp-block-code"><code>az containerapp show \
  --resource-group rg-demo-stg-001 \
  --name ca-demo-backend-dev-001 \
  --query "{identity:identity,environment:properties.managedEnvironmentId,registries:properties.configuration.registries&#91;].{server:server,identity:identity},images:properties.template.containers&#91;].image}" \
  -o json</code></pre>



<p class="wp-block-paragraph">檢查 runtime identity 是否有 ACR <code>AcrPull</code>：</p>



<pre class="wp-block-code"><code>az role assignment list \
  --assignee-object-id &lt;BACKEND_APP_PRINCIPAL_ID&gt; \
  --scope "$(az acr show \
    --resource-group rg-demo-stg-001 \
    --name acrdemostg001 \
    --query id -o tsv)" \
  --query "&#91;].{role:roleDefinitionName,scope:scope}" \
  -o table</code></pre>



<p class="wp-block-paragraph">Frontend 使用自己的 <code>&lt;FRONTEND_APP_PRINCIPAL_ID&gt;</code> 查詢，不要共用 Backend 的 identity。</p>



<p class="wp-block-paragraph">如果 App 沒有 <code>AcrPull</code>，常見結果是：</p>



<ul class="wp-block-list">
<li><code>az containerapp update</code> 指令本身成功。</li>



<li>revision 建立了，但狀態是 <code>ActivationFailed</code>。</li>



<li>logs 或 revision events 出現 image pull、registry authentication 或 unauthorized 錯誤。</li>
</ul>



<p class="wp-block-paragraph">這時應該補在 <strong>Container App 的 Managed Identity</strong>，不能把 <code>AcrPull</code> 加到 GitHub OIDC Service Principal 代替。</p>



<h2 class="wp-block-heading">Backend 存取 SQL Database</h2>



<h3 class="wp-block-heading">SQL 身分的分工</h3>



<p class="wp-block-paragraph">Backend 不應把 SQL username/password 寫進 GitHub Secrets 或 container environment。建議使用：</p>



<ol class="wp-block-list">
<li>Backend App 的 system-assigned Managed Identity 取得 Microsoft Entra token。</li>



<li>SQL Entra administrator 在 <code>demoproject-dev</code> 建立對應的 contained user。</li>



<li>只授予 Backend 實際需要的 database roles。</li>
</ol>



<p class="wp-block-paragraph">示範用的 database URL 概念如下：</p>



<pre class="wp-block-code"><code>sqlserver://sql-demo-stg-001.database.windows.net:1433
  ?database=demoproject-dev
  &amp;encrypt=true
  &amp;trustservercertificate=false
  &amp;fedauth=ActiveDirectoryManagedIdentity
  &amp;useMsi=true</code></pre>



<h3 class="wp-block-heading">建立 contained user 與 database roles</h3>



<p class="wp-block-paragraph">以下 SQL 必須由 SQL Microsoft Entra administrator 或有足夠 database 權限的管理者，在 <code>demoproject-dev</code> database 執行：</p>



<pre class="wp-block-code"><code>CREATE USER &#91;ca-demo-backend-dev-001] FROM EXTERNAL PROVIDER;</code></pre>



<p class="wp-block-paragraph">再依照應用程式需要授予角色：</p>



<pre class="wp-block-code"><code>ALTER ROLE &#91;db_datareader]
ADD MEMBER &#91;ca-demo-backend-dev-001];

ALTER ROLE &#91;db_datawriter]
ADD MEMBER &#91;ca-demo-backend-dev-001];

-- 只有需要由 App 執行 schema migration 時才授予
ALTER ROLE &#91;db_ddladmin]
ADD MEMBER &#91;ca-demo-backend-dev-001];</code></pre>



<p class="wp-block-paragraph">這些是 SQL Database 裡的 data-plane permissions，不是 Azure Resource Group RBAC。因此 <code>az role assignment list</code> 看不到 <code>db_datareader</code>、<code>db_datawriter</code> 或 <code>db_ddladmin</code> membership。</p>



<p class="wp-block-paragraph">可以在 SQL 中確認：</p>



<pre class="wp-block-code"><code>SELECT
    dp.name AS database_principal,
    rp.name AS database_role
FROM sys.database_role_members AS drm
JOIN sys.database_principals AS rp
    ON rp.principal_id = drm.role_principal_id
JOIN sys.database_principals AS dp
    ON dp.principal_id = drm.member_principal_id
WHERE dp.name = N'ca-demo-backend-dev-001';</code></pre>



<p class="wp-block-paragraph">Frontend 不需要 SQL 權限，也不應為 Frontend 建立 SQL contained user。</p>



<h2 class="wp-block-heading">SQL 網路路徑</h2>



<p class="wp-block-paragraph">即使 SQL user/roles 都正確，網路不通時仍然無法連線。</p>



<p class="wp-block-paragraph">Demo 情境中的網路設計是：</p>



<ul class="wp-block-list">
<li><code>pe-sql-demo-stg-001</code> 的狀態為 Approved。</li>



<li>Private DNS zone <code>privatelink.database.windows.net</code> 連結到 <code>vnet-demo-stg-001</code>。</li>



<li>共用 <code>cae-demo-stg-001</code> 有正確的 VNet/infrastructure subnet 路徑。</li>



<li>App 放在共用 CAE，才能透過 Private Endpoint 解析並連線到 SQL。</li>
</ul>



<p class="wp-block-paragraph">舊的 <code>cae-example-dev-001</code> 沒有這條 infrastructure subnet 路徑，因此不適合作為需要存取 Private SQL 的 target。</p>



<p class="wp-block-paragraph">看到下面的錯誤時，優先檢查 CAE、VNet、Private DNS 和 Private Endpoint：</p>



<pre class="wp-block-code"><code>Client with IP address ... is not allowed to access the server</code></pre>



<p class="wp-block-paragraph">不要為了繞過問題，直接把 SQL firewall 開成 <code>0.0.0.0</code>。這會把網路問題變成不必要的公開暴露。</p>



<p class="wp-block-paragraph">看到下面的錯誤時，優先檢查 Managed Identity、contained user 和 database roles：</p>



<pre class="wp-block-code"><code>Login failed</code></pre>



<h2 class="wp-block-heading">開發者帳號能做什麼</h2>



<p class="wp-block-paragraph">示範開發者帳號是 <code>demo.user@example.com</code>，在 demo tenant 中的 UPN 示意如下：</p>



<pre class="wp-block-code"><code>demo_user_example.com#EXT#@exampletenant.onmicrosoft.com</code></pre>



<p class="wp-block-paragraph">示範直接授予的 Azure roles：</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Scope</th><th>Role</th><th>可以做什麼</th></tr></thead><tbody><tr><td><code>rg-demo-stg-001</code></td><td><code>Contributor</code></td><td>在 App RG 建立、更新、刪除 Container App</td></tr><tr><td><code>rg-demo-stg-001</code></td><td><code>Reader</code></td><td>讀取 RG 資源</td></tr><tr><td><code>acrdemostg001</code></td><td><code>AcrPush</code></td><td>Push image 到 ACR</td></tr><tr><td><code>cae-demo-stg-001</code></td><td><code>Reader</code></td><td>讀取共用 CAE 設定</td></tr><tr><td><code>cae-demo-stg-001</code></td><td><code>Container Apps Environment Joiner</code></td><td>建立 App 時加入共用 CAE</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">因此，這個帳號可以：</p>



<ul class="wp-block-list">
<li>建立或更新 App。</li>



<li>將 App 放進共用 <code>cae-demo-stg-001</code>。</li>



<li>Push image 到 ACR。</li>
</ul>



<p class="wp-block-paragraph">但這個帳號不能：</p>



<ul class="wp-block-list">
<li>建立 Azure RBAC role assignment。</li>



<li>替新的 App 授予 ACR <code>AcrPull</code>。</li>



<li>修改 SQL Private Endpoint、VNet 或 SQL firewall。</li>



<li>在 <code>demoproject-dev</code> 建立 Entra contained user。</li>



<li>授予或修改 SQL database roles。</li>
</ul>



<p class="wp-block-paragraph">這是刻意的權限邊界：開發者可以部署應用程式，但不能自行擴大 App 的 Azure 權限或資料庫權限。需要新增 <code>AcrPull</code> 或 SQL user/roles 時，應請平台管理者或 SQL Entra administrator 處理。</p>



<h2 class="wp-block-heading">舊 App 如何切換到共用 CAE</h2>



<p class="wp-block-paragraph">Azure Container Apps 沒有提供用 <code>az containerapp update</code> 直接切換 Environment 的參數。既有 App 不能像更新 image 一樣，直接從：</p>



<pre class="wp-block-code"><code>cae-example-dev-001</code></pre>



<p class="wp-block-paragraph">搬到：</p>



<pre class="wp-block-code"><code>cae-demo-stg-001</code></pre>



<p class="wp-block-paragraph">較安全的切換方式是：</p>



<ol class="wp-block-list">
<li>在共用 CAE 建立新的 Container App，或使用已準備好的 <code>ca-demo-backend-dev-001</code> / <code>ca-demo-frontend-dev-001</code>。</li>



<li>逐項複製必要的 environment variables、secrets、ingress、domain、scale 和 traffic 設定。</li>



<li>啟用新 App 的 system-assigned Managed Identity。</li>



<li>由平台管理者授予新 identity ACR <code>AcrPull</code>。</li>



<li>由 SQL Entra administrator 替新的 Backend identity 建立 SQL user/roles。</li>



<li>將 CI/CD 的 target App name 改成新的 App。</li>



<li>驗證 revision、logs、API、Frontend URL、ACR pull 與 SQL 連線。</li>



<li>新 App 穩定後，才考慮移除舊 App。</li>
</ol>



<p class="wp-block-paragraph">不要先刪除舊 App 再重建，因為可能遺失 secrets、domain、ingress、traffic 和原本的部署設定。</p>



<h2 class="wp-block-heading">Frontend 的特別注意事項</h2>



<p class="wp-block-paragraph">Frontend 只需要：</p>



<ul class="wp-block-list">
<li>從 ACR pull image。</li>



<li>連到正確的 Backend URL。</li>



<li>必要時讀取自己的 Key Vault secrets。</li>
</ul>



<p class="wp-block-paragraph">Frontend 不需要 <code>demoproject-dev</code> 的 SQL roles。</p>



<p class="wp-block-paragraph">如果 Frontend revision 是 <code>ActivationFailed</code>，但 <code>AcrPull</code> 已經正確，請檢查：</p>



<ul class="wp-block-list">
<li>image 內的 Nginx upstream 設定。</li>



<li><code>BACKEND_URL</code> 或其他 Backend endpoint environment variable。</li>



<li>image 是否仍然包含舊的 <code>backend.legacy.example.invalid</code> hostname。</li>



<li>新 image 是否真的已 push 到 ACR，且 App 使用了正確 tag。</li>
</ul>



<p class="wp-block-paragraph">這類問題通常是 image/configuration 問題，不是 SQL 權限問題。</p>



<h2 class="wp-block-heading">建議的執行順序</h2>



<p class="wp-block-paragraph">平台管理者與開發團隊可以依照以下順序處理：</p>



<ol class="wp-block-list">
<li>確認 GitHub OIDC Federated Credential 的 repository、branch 和 Environment subject 正確。</li>



<li>確認 CI Service Principal 只有部署所需的 <code>Contributor</code>、<code>AcrPush</code> 等角色。</li>



<li>確認 Backend 和 Frontend 都位於 <code>cae-demo-stg-001</code>。</li>



<li>啟用兩個 App 的 system-assigned identity。</li>



<li>對兩個 App identity 各自授予 ACR <code>AcrPull</code>。</li>



<li>執行 <code>az containerapp registry set --identity system</code>。</li>



<li>由 SQL Entra administrator 建立 Backend contained user 和必要 roles。</li>



<li>確認 CAE、VNet、Private DNS、Private Endpoint 的網路路徑。</li>



<li>只啟用一條正式部署路徑，避免舊 pipeline 和 Azure CI/CD 同時更新同一個 App。</li>



<li>做一次受控部署，逐項檢查 image、revision、logs、API、Frontend 和 SQL。</li>
</ol>



<h2 class="wp-block-heading">部署後驗證指令</h2>



<h3 class="wp-block-heading">確認 ACR image tag</h3>



<pre class="wp-block-code"><code>az acr repository show-tags \
  --name acrdemostg001 \
  --repository demo-backend \
  --orderby time_desc \
  --top 5 \
  -o table</code></pre>



<h3 class="wp-block-heading">確認 Backend revision</h3>



<pre class="wp-block-code"><code>az containerapp revision list \
  --resource-group rg-demo-stg-001 \
  --name ca-demo-backend-dev-001 \
  --query "&#91;].{name:name,health:properties.healthState,running:properties.runningState,traffic:properties.trafficWeight}" \
  -o table</code></pre>



<p class="wp-block-paragraph">正常情況應看到 revision 為 <code>Healthy</code>，且 running state 正常。若 revision 建立成功但沒有 running，請查 revision events 和 container logs。</p>



<h3 class="wp-block-heading">查看 Backend logs</h3>



<pre class="wp-block-code"><code>az containerapp logs show \
  --resource-group rg-demo-stg-001 \
  --name ca-demo-backend-dev-001 \
  --type console \
  --tail 100</code></pre>



<h3 class="wp-block-heading">最後確認的成功條件</h3>



<ul class="wp-block-list">
<li>GitHub Actions OIDC login 成功。</li>



<li>image 成功 push 到 <code>acrdemostg001.azurecr.io</code>。</li>



<li>Backend 和 Frontend revision 能從 ACR pull image。</li>



<li>Backend revision 為 <code>Healthy / Running</code>。</li>



<li>Backend 能以 Managed Identity 取得 SQL token。</li>



<li>SQL contained user 與必要 roles 存在。</li>



<li>Backend 能連線到 <code>demoproject-dev</code>。</li>



<li>Frontend 使用新的 Backend URL。</li>



<li>舊的部署系統不會再覆蓋 Azure CI/CD 的結果。</li>
</ul>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/09/azure-container-apps-ci-cd/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Azure Container Apps 與 GitHub OIDC 無密碼部署實戰：新團隊安全上手指南</title>
		<link>https://stackoverflow.max-everyday.com/2026/09/azure-container-apps-github-oidc/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/09/azure-container-apps-github-oidc/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Sun, 13 Sep 2026 03:49:54 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[azure]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8757</guid>

					<description><![CDATA[企業或專案引進「新開發團隊」接手部署時，常見的權...]]></description>
										<content:encoded><![CDATA[
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="572" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_5923070288072847847_clean-1024x572.jpg?v=1789271607" alt="" class="wp-image-8760" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_5923070288072847847_clean-1024x572.jpg?v=1789271607 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_5923070288072847847_clean-600x335.jpg?v=1789271607 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_5923070288072847847_clean-767x428.jpg?v=1789271607 767w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_5923070288072847847_clean.jpg?v=1789271607 1376w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">企業或專案引進「新開發團隊」接手部署時，常見的權限混亂、安全風險與網路連線問題。主要包含以下四大核心問題：</p>



<ul class="wp-block-list">
<li><strong>權限與安全問題</strong>：避免為了部署而過度授予高風險權限（例如 Azure Owner 或 SQL 權限），並改用 GitHub Actions OIDC 取代長期保存的明文金鑰（ secrets ）。</li>



<li><strong>責任分工不明確</strong>：清楚劃分「平台管理者」與「開發團隊」的職責邊界，防止開發團隊誤動基礎架構（如 SQL Firewall、VNet ）。</li>



<li><strong>網路連線與架構問題</strong>：解決 Container App 因未走正確的 VNet 與 Private Endpoint 導致無法連線至 Azure SQL Database 的問題。</li>



<li><strong>新手上手缺乏規範</strong>：提供明確的跨團隊開發指南、 CI/CD 自動化流程範本與開通檢查表，縮短團隊上手時間。</li>
</ul>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><code>demoproject</code>&nbsp;與&nbsp;<code>ExampleOrg</code>&nbsp;只是示範名稱。正式環境使用前，必須由 平台管理者替換所有&nbsp;<code>&lt;PLACEHOLDER&gt;</code>&nbsp;與範例資源名稱，並確認權限範圍。</p>
</blockquote>



<h2 class="wp-block-heading">1. 先看結論</h2>



<p class="wp-block-paragraph">新加入的開發團隊平常只需要：</p>



<ol class="wp-block-list">
<li>取得 GitHub repository 與必要的 Azure RBAC 權限。</li>



<li>使用平台管理者準備好的共用 Container Apps Environment。</li>



<li>透過 GitHub Actions OIDC 登入 Azure，不使用長期 client secret。</li>



<li>Build Docker image、push 到 ACR、更新既有 Container App。</li>



<li>透過 revision 與 logs 查看部署結果。</li>
</ol>



<p class="wp-block-paragraph">新加入的開發團隊<strong>不需要</strong>：</p>



<ul class="wp-block-list">
<li>Azure <code>Owner</code>。</li>



<li>SQL Resource Group 的 <code>Contributor</code>。</li>



<li>直接修改 SQL firewall、Private Endpoint、VNet 或 Private DNS。</li>



<li>在 GitHub repository 中存放 SQL 密碼或應用程式 secrets。</li>



<li>每次部署都執行完整的基礎資源建立流程。</li>
</ul>



<p class="wp-block-paragraph">應用程式連接 SQL 的權限不是由 GitHub Actions Service Principal 提供， 而是由 Backend Container App 自己的 Managed Identity 在資料庫中取得。</p>



<h2 class="wp-block-heading">2. Azure 服務白話說明</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th class="has-text-align-left" data-align="left">Azure 服務或名詞</th><th class="has-text-align-left" data-align="left">白話說明</th><th class="has-text-align-left" data-align="left">DemoProject 用途</th></tr></thead><tbody><tr><td>Subscription</td><td>Azure 帳單與資源的管理範圍</td><td><code>&lt;SUBSCRIPTION_NAME&gt;</code></td></tr><tr><td>Resource Group（RG）</td><td>把相關 Azure 資源放在一起的資料夾</td><td>App、ACR、CAE、SQL 分開管理</td></tr><tr><td>Azure Container Registry（ACR）</td><td>存放 Docker image 的私有倉庫</td><td>CI push image</td></tr><tr><td>Container Apps Environment（CAE）</td><td>Container Apps 共用的執行環境與網路邊界</td><td>Backend、Frontend 共用</td></tr><tr><td>Container App（CA）</td><td>實際執行容器的 Azure 資源</td><td>執行 DemoProject Backend/Frontend</td></tr><tr><td>Virtual Network（VNet）</td><td>Azure 內部的私有網路</td><td>連接 CAE 與 SQL Private Endpoint</td></tr><tr><td>Private Endpoint（PE）</td><td>讓 Azure 服務在 VNet 內取得私有 IP</td><td>將 SQL 接到 VNet</td></tr><tr><td>Private DNS</td><td>讓 hostname 在 VNet 內解析到私有 IP</td><td>解析 SQL hostname</td></tr><tr><td>Azure SQL Server / Database</td><td>SQL Server 與其中的資料庫</td><td>存放 DemoProject 資料</td></tr><tr><td>Managed Identity</td><td>Azure 資源自己的身分，不需要存密碼</td><td>App 登入 SQL、ACR、Key Vault</td></tr><tr><td>Key Vault</td><td>集中存放 secret、憑證與金鑰</td><td>Backend runtime 讀取 secrets</td></tr><tr><td>Azure RBAC</td><td>管理 Azure 資源的控制權</td><td>決定誰能部署或修改 App</td></tr><tr><td>SQL database role</td><td>資料庫內的資料讀寫權限</td><td><code>db_datareader</code>、<code>db_datawriter</code>、<code>db_ddladmin</code></td></tr><tr><td>GitHub OIDC</td><td>GitHub workflow 使用短期 token 登入 Azure</td><td>CI 不存放 client secret</td></tr></tbody></table></figure>



<h3 class="wp-block-heading">Azure RBAC 與 SQL 權限不是同一件事</h3>



<ul class="wp-block-list">
<li>Azure <code>Contributor</code> 可以管理 Azure Resource Manager 資源，不等於可以 查詢 SQL table。</li>



<li>SQL <code>db_datareader</code> 可以讀資料，不等於可以修改 Azure SQL Server。</li>



<li>SQL <code>db_datawriter</code> 可以寫資料。</li>



<li>SQL <code>db_ddladmin</code> 可以執行部分資料庫結構變更，應只授予需要執行 migration 的 Backend identity。</li>



<li>SQL firewall 與 Private Endpoint 決定「網路能不能到 SQL」；資料庫 roles 只有在網路已連通後才會生效。</li>
</ul>



<h2 class="wp-block-heading">3. DemoProject 資源地圖</h2>



<p class="wp-block-paragraph">以下名稱全部是示範值：</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th class="has-text-align-left" data-align="left">用途</th><th class="has-text-align-left" data-align="left">Resource Group</th><th class="has-text-align-left" data-align="left">資源名稱</th></tr></thead><tbody><tr><td>Azure Container Registry</td><td><code>rg-demoproject-stg-app</code></td><td><code>acr-demoproject-stg.azurecr.io</code></td></tr><tr><td>Container Apps</td><td><code>rg-demoproject-stg-app</code></td><td><code>ca-demoproject-backend-stg</code>、<code>ca-demoproject-frontend-stg</code></td></tr><tr><td>共用 Container Apps Environment</td><td><code>rg-demoproject-stg-cae</code></td><td><code>cae-demoproject-stg</code></td></tr><tr><td>SQL Server / Database</td><td><code>rg-demoproject-stg-data</code></td><td><code>sql-demoproject-stg.database.windows.net</code>&nbsp;/&nbsp;<code>demoproject-dev</code></td></tr><tr><td>SQL Private Endpoint</td><td><code>rg-demoproject-stg-network</code></td><td><code>pe-demoproject-sql-stg</code></td></tr><tr><td>Key Vault</td><td><code>rg-demoproject-stg-app</code></td><td><code>kv-demoproject-stg</code></td></tr><tr><td>VNet</td><td><code>rg-demoproject-stg-network</code></td><td><code>vnet-demoproject-stg</code></td></tr></tbody></table></figure>



<h3 class="wp-block-heading">3.1 建議的網路路徑</h3>



<pre class="wp-block-code"><code>Backend Container App
    |
    |  共用 CAE 的 VNet 網路
    v
vnet-demoproject-stg
    |
    |  Private DNS 將 SQL hostname 解析到私有 IP
    v
SQL Private Endpoint pe-demoproject-sql-stg
    |
    v
sql-demoproject-stg / demoproject-dev
</code></pre>



<p class="wp-block-paragraph">如果 Container App 位於沒有 VNet/private DNS 的獨立 CAE，它可能會透過 公網出口 IP 連線 SQL，並收到：</p>



<pre class="wp-block-code"><code>Client with IP address '...' is not allowed to access the server
</code></pre>



<p class="wp-block-paragraph">遇到這個錯誤時，先確認 App 所在 CAE 與 DNS 路徑，不要立刻把大量公網 IP 加進 SQL firewall。</p>



<h2 class="wp-block-heading">4. 權限模型</h2>



<h3 class="wp-block-heading">4.1 開發者與 CI 的建議權限</h3>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th class="has-text-align-left" data-align="left">範圍</th><th class="has-text-align-left" data-align="left">建議角色/權限</th><th class="has-text-align-left" data-align="left">用途</th><th class="has-text-align-left" data-align="left">SQL data access</th></tr></thead><tbody><tr><td>既有 Backend Container App</td><td><code>Contributor</code>&nbsp;或專用 custom role</td><td>更新 image/revision</td><td>否</td></tr><tr><td>既有 Frontend Container App</td><td><code>Contributor</code>&nbsp;或專用 custom role</td><td>更新 image/revision</td><td>否</td></tr><tr><td>ACR</td><td><code>AcrPush</code></td><td>Docker image push</td><td>否</td></tr><tr><td>共用 CAE</td><td><code>Reader</code></td><td>讀取 CAE 設定</td><td>否</td></tr><tr><td>共用 CAE</td><td><code>Container Apps Environment Joiner</code></td><td>使用指定 CAE</td><td>否</td></tr><tr><td>SQL Resource Group</td><td>不授權</td><td>SQL 集中由管理者控管</td><td>否</td></tr><tr><td>Backend Managed Identity</td><td>SQL contained user + SQL roles</td><td>應用程式 runtime 連線 SQL</td><td>是</td></tr></tbody></table></figure>



<p class="wp-block-paragraph"><code>Container Apps Environment Joiner</code>&nbsp;應只包含：</p>



<pre class="wp-block-code"><code>Microsoft.App/managedEnvironments/join/action
</code></pre>



<p class="wp-block-paragraph">不要為了讓團隊部署而授予共用 CAE Resource Group 的&nbsp;<code>Contributor</code>。</p>



<h3 class="wp-block-heading">4.2 平台管理者與開發團隊分工</h3>



<p class="wp-block-paragraph">平台管理者一次性完成：</p>



<ol class="wp-block-list">
<li>建立 App Registration 與 Service Principal。</li>



<li>建立指定 repository、branch 或 GitHub Environment 的 Federated Credential。</li>



<li>在 ACR scope 授予 <code>AcrPush</code>。</li>



<li>在既有 Container App scope 授予更新權限。</li>



<li>在共用 CAE scope 授予 <code>Reader</code> 與 <code>Container Apps Environment Joiner</code>。</li>



<li>確認 App runtime Managed Identity 具備 ACR、Key Vault 與 SQL 權限。</li>



<li>確認 App 位於正確的 VNet/private DNS CAE。</li>
</ol>



<p class="wp-block-paragraph">開發團隊負責：</p>



<ol class="wp-block-list">
<li>維護 GitHub Actions workflow。</li>



<li>Build Docker image。</li>



<li>Push image 到 ACR。</li>



<li>更新既有 Container App。</li>



<li>查看 revision 與 logs。</li>
</ol>



<p class="wp-block-paragraph">開發團隊不應該：</p>



<ul class="wp-block-list">
<li>建立或刪除 Service Principal。</li>



<li>修改 SQL firewall、Private Endpoint、VNet 或 Private DNS。</li>



<li>將 App secrets 寫入 repository 或 workflow。</li>



<li>執行 SQL <code>CREATE USER</code>。</li>



<li>修改共用 CAE 的網路設定。</li>
</ul>



<h2 class="wp-block-heading">5. GitHub Actions OIDC</h2>



<h3 class="wp-block-heading">5.1 OIDC 如何運作</h3>



<pre class="wp-block-code"><code>GitHub push to main
        |
        | GitHub 發出短期 OIDC token
        v
GitHub Actions
        |
        | azure/login@v2
        v
Microsoft Entra App / Service Principal
        |
        | Federated Credential 比對 repo + branch/environment
        | Azure RBAC 比對允許的 scope
        v
ACR push -&gt; Container App update
</code></pre>



<p class="wp-block-paragraph">OIDC 不等於自動擁有 Azure 權限：</p>



<ul class="wp-block-list">
<li>Federated Credential 決定「哪個 GitHub workflow 可以登入」。</li>



<li>Azure RBAC 決定「登入後可以做什麼」。</li>
</ul>



<h3 class="wp-block-heading">5.2 新團隊專用 Service Principal</h3>



<p class="wp-block-paragraph">建議每個團隊、每個環境使用不同 Service Principal。不要讓所有團隊共用 一個可寫入 Staging 的 identity。</p>



<p class="wp-block-paragraph">以下是示範值：</p>



<pre class="wp-block-code"><code>SP display name: demoproject-team-a-staging-ci
Client ID:       &lt;APP_CLIENT_ID&gt;
Object ID:       &lt;SERVICE_PRINCIPAL_OBJECT_ID&gt;
Tenant ID:       &lt;TENANT_ID&gt;
Subscription ID: &lt;SUBSCRIPTION_ID&gt;
</code></pre>



<p class="wp-block-paragraph">不需要建立 client secret。</p>



<h3 class="wp-block-heading">5.3 角色配置範例</h3>



<p class="wp-block-paragraph">以下指令只展示格式，執行前請替換所有 placeholder：</p>



<pre class="wp-block-code"><code>set -euo pipefail

SUB_ID="&lt;SUBSCRIPTION_ID&gt;"
APP_RG="rg-demoproject-stg-app"
CAE_RG="rg-demoproject-stg-cae"
CAE_NAME="cae-demoproject-stg"
ACR_NAME="acr-demoproject-stg"
SP_NAME="demoproject-team-a-staging-ci"

CAE_SCOPE="$(az containerapp env show \
  --subscription "$SUB_ID" \
  --resource-group "$CAE_RG" \
  --name "$CAE_NAME" \
  --query id -o tsv)"

ACR_SCOPE="$(az acr show \
  --subscription "$SUB_ID" \
  --resource-group "$APP_RG" \
  --name "$ACR_NAME" \
  --query id -o tsv)"

APP_ID="$(az ad app create \
  --display-name "$SP_NAME" \
  --query appId -o tsv)"

SP_OBJECT_ID="$(az ad sp create \
  --id "$APP_ID" \
  --query id -o tsv)"

az role assignment create \
  --subscription "$SUB_ID" \
  --assignee-object-id "$SP_OBJECT_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "AcrPush" \
  --scope "$ACR_SCOPE"

az role assignment create \
  --subscription "$SUB_ID" \
  --assignee-object-id "$SP_OBJECT_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "Reader" \
  --scope "$CAE_SCOPE"

az role assignment create \
  --subscription "$SUB_ID" \
  --assignee-object-id "$SP_OBJECT_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "Container Apps Environment Joiner" \
  --scope "$CAE_SCOPE"
</code></pre>



<p class="wp-block-paragraph">若 CI 只更新既有 App，建議把寫入權限限制在 App resource scope：</p>



<pre class="wp-block-code"><code>BACKEND_SCOPE="$(az containerapp show \
  --subscription "$SUB_ID" \
  --resource-group "$APP_RG" \
  --name "ca-demoproject-backend-stg" \
  --query id -o tsv)"

az role assignment create \
  --subscription "$SUB_ID" \
  --assignee-object-id "$SP_OBJECT_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "Contributor" \
  --scope "$BACKEND_SCOPE"
</code></pre>



<p class="wp-block-paragraph">不要把 CI identity 授予：</p>



<pre class="wp-block-code"><code>rg-demoproject-stg-data
rg-demoproject-stg-cae 的 Contributor
demoproject-dev 的 SQL roles
</code></pre>



<h3 class="wp-block-heading">5.4 Federated Credential</h3>



<h4 class="wp-block-heading">方案 A：只允許 main branch</h4>



<p class="wp-block-paragraph">適合單一 staging deploy workflow：</p>



<pre class="wp-block-code"><code>REPO="ExampleOrg/demoproject-backend"
SAFE_NAME="${REPO//\//-}-main"
SUBJECT="repo:${REPO}:ref:refs/heads/main"

PAYLOAD="$(jq -n \
  --arg name "$SAFE_NAME" \
  --arg subject "$SUBJECT" \
  '{
    name: $name,
    issuer: "https://token.actions.githubusercontent.com",
    subject: $subject,
    audiences: &#91;"api://AzureADTokenExchange"]
  }')"

az ad app federated-credential create \
  --id "$APP_ID" \
  --parameters "$PAYLOAD"
</code></pre>



<h4 class="wp-block-heading">方案 B：使用受保護的 GitHub Environment</h4>



<p class="wp-block-paragraph">新團隊建議建立 GitHub Environment&nbsp;<code>staging</code>，設定：</p>



<ul class="wp-block-list">
<li>Required reviewers。</li>



<li>只允許 <code>main</code> branch 或指定 tag。</li>



<li>將 Azure identifiers 放在 Environment variables。</li>
</ul>



<p class="wp-block-paragraph">Federated Credential 的 subject 改為：</p>



<pre class="wp-block-code"><code>repo:ExampleOrg/demoproject-backend:environment:staging
</code></pre>



<p class="wp-block-paragraph">Workflow 的 deploy job 必須包含：</p>



<pre class="wp-block-code"><code>environment: staging
</code></pre>



<p class="wp-block-paragraph">Branch subject 與 Environment subject 不要混用。Azure 會比對完整的&nbsp;<code>sub</code>&nbsp;claim，字串不完全相同就會登入失敗。</p>



<h4 class="wp-block-heading">PR 不可取得 write token</h4>



<p class="wp-block-paragraph">以下 subject 不可綁定到具有&nbsp;<code>Contributor</code>、<code>AcrPush</code>&nbsp;或 App write 的 identity：</p>



<pre class="wp-block-code"><code>repo:ExampleOrg/demoproject-backend:pull_request
</code></pre>



<p class="wp-block-paragraph">PR 程式碼尚未審核，可能修改 workflow 並呼叫 Azure CLI。PR workflow 應只 執行 lint、test、build；若真的需要查詢 Azure，另建只有&nbsp;<code>Reader</code>&nbsp;的 identity。</p>



<h2 class="wp-block-heading">6. GitHub Actions Workflow 範本</h2>



<p class="wp-block-paragraph">以下範例使用受保護的&nbsp;<code>staging</code>&nbsp;Environment。若使用 main branch subject， 請移除&nbsp;<code>environment: staging</code>，或先建立相符的 Environment Federated Credential。</p>



<pre class="wp-block-code"><code>name: Deploy DemoProject Backend

on:
  push:
    branches:
      - main

permissions:
  contents: read
  id-token: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Login to Azure with OIDC
        uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: Login to ACR
        run: az acr login --name acr-demoproject-stg

      - name: Build and push image
        env:
          IMAGE: acr-demoproject-stg.azurecr.io/demoproject-backend:${{ github.sha }}
        run: |
          docker build -t "$IMAGE" .
          docker push "$IMAGE"

      - name: Update existing Container App
        env:
          IMAGE: acr-demoproject-stg.azurecr.io/demoproject-backend:${{ github.sha }}
        run: |
          az containerapp update \
            --resource-group rg-demoproject-stg-app \
            --name ca-demoproject-backend-stg \
            --image "$IMAGE"
</code></pre>



<p class="wp-block-paragraph">Frontend 只需要替換 image repository 與 Container App name。</p>



<h3 class="wp-block-heading">6.1 PR Workflow</h3>



<p class="wp-block-paragraph">PR workflow 不應取得 Azure write token：</p>



<pre class="wp-block-code"><code>on:
  pull_request:
    branches:
      - main

permissions:
  contents: read
</code></pre>



<p class="wp-block-paragraph">PR 可執行 lint、unit test、build 或不涉及 Staging 的 container build， 但不要：</p>



<ul class="wp-block-list">
<li><code>id-token: write</code>。</li>



<li><code>azure/login</code>。</li>



<li>Push Staging ACR。</li>



<li>Update 或 delete Container App。</li>
</ul>



<h3 class="wp-block-heading">6.2 不要在 CI 執行完整部署腳本</h3>



<p class="wp-block-paragraph">完整部署腳本通常會包含 secrets、Key Vault policy、Managed Identity、 SQL 授權與首次建立資源的流程，不適合直接放進 GitHub Actions。</p>



<p class="wp-block-paragraph">CI 建議只做：</p>



<pre class="wp-block-code"><code>OIDC login
  -&gt; docker build
  -&gt; ACR push
  -&gt; containerapp update --image
</code></pre>



<p class="wp-block-paragraph">既有的環境變數與 secrets 由平台管理者預先設定；App runtime 使用自己的 Managed Identity 連接 SQL 與 Key Vault。</p>



<h2 class="wp-block-heading">7. SQL Database 與 Managed Identity</h2>



<h3 class="wp-block-heading">7.1 App runtime 才需要 SQL role</h3>



<p class="wp-block-paragraph">查詢 Backend App 的 Managed Identity：</p>



<pre class="wp-block-code"><code>az containerapp show \
  --subscription "&lt;SUBSCRIPTION_ID&gt;" \
  --resource-group "rg-demoproject-stg-app" \
  --name "ca-demoproject-backend-stg" \
  --query "{type:identity.type,principalId:identity.principalId}" \
  -o json
</code></pre>



<p class="wp-block-paragraph">如果 Container App 被刪除後重新建立，system-assigned identity 可能改變。 管理者需要重新確認 ACR、Key Vault 與 SQL 權限。</p>



<h3 class="wp-block-heading">7.2 建立 SQL contained user</h3>



<p class="wp-block-paragraph">以下 SQL 應由 SQL Entra administrator 或等效資料庫管理員，在&nbsp;<code>demoproject-dev</code>&nbsp;執行。將 App name 替換成實際值：</p>



<pre class="wp-block-code"><code>IF DATABASE_PRINCIPAL_ID(N'ca-demoproject-backend-stg') IS NULL
BEGIN
    CREATE USER &#91;ca-demoproject-backend-stg] FROM EXTERNAL PROVIDER;
END;

ALTER ROLE &#91;db_datareader]
ADD MEMBER &#91;ca-demoproject-backend-stg];

ALTER ROLE &#91;db_datawriter]
ADD MEMBER &#91;ca-demoproject-backend-stg];

<em>-- 只有 Backend migration 確實需要時才授予</em>
ALTER ROLE &#91;db_ddladmin]
ADD MEMBER &#91;ca-demoproject-backend-stg];
</code></pre>



<p class="wp-block-paragraph">CI Service Principal 不需要執行這段 SQL，也不應被加入這三個 database roles。</p>



<h3 class="wp-block-heading">7.3 人員需要直接查詢資料庫</h3>



<p class="wp-block-paragraph">不要把 Azure&nbsp;<code>Contributor</code>&nbsp;當成 SQL data access。若確實需要讓人員直接 查詢：</p>



<ol class="wp-block-list">
<li>管理者建立 Microsoft Entra security group。</li>



<li>將需要查詢的人員加入該群組。</li>



<li>在 <code>demoproject-dev</code> 建立該群組的 contained user。</li>



<li>優先只授予 <code>db_datareader</code>。</li>



<li>確認使用者有 VNet、VPN 或其他 Private Endpoint 網路路徑。</li>
</ol>



<h2 class="wp-block-heading">8. Private Endpoint 與 SQL Firewall</h2>



<h3 class="wp-block-heading">8.1 為什麼不能只新增一個 IP？</h3>



<p class="wp-block-paragraph">獨立的 Container Apps Environment 可能使用公網出口 IP，而且出口 IP 可能有很多個或因環境變更而改變。錯誤訊息中的 IP 只代表當次連線， 不一定是永久 IP。</p>



<p class="wp-block-paragraph">使用共用 CAE 時，應該走：</p>



<pre class="wp-block-code"><code>Container App
  -&gt; VNet
  -&gt; Private DNS
  -&gt; SQL Private Endpoint
  -&gt; SQL Database
</code></pre>



<p class="wp-block-paragraph">不要用大量公網 IP firewall 規則取代正確的網路拓撲，也不要未經核准就 建立：</p>



<pre class="wp-block-code"><code>start-ip-address = 0.0.0.0
end-ip-address   = 0.0.0.0
</code></pre>



<p class="wp-block-paragraph">這代表允許大範圍 Azure services 存取，不是只允許本團隊。</p>



<h3 class="wp-block-heading">8.2 確認 App 使用正確 CAE</h3>



<pre class="wp-block-code"><code>$expected = "/subscriptions/&lt;SUBSCRIPTION_ID&gt;/resourceGroups/rg-demoproject-stg-cae/providers/Microsoft.App/managedEnvironments/cae-demoproject-stg"

az containerapp show `
  --name ca-demoproject-backend-stg `
  --resource-group rg-demoproject-stg-app `
  --query properties.managedEnvironmentId `
  -o tsv

$expected
</code></pre>



<p class="wp-block-paragraph">兩個輸出必須相同。如果 App 位於沒有 VNet/private DNS 的 CAE，先請平台 管理者確認網路路徑，不要先修改 SQL firewall。</p>



<h3 class="wp-block-heading">8.3 常見錯誤</h3>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th class="has-text-align-left" data-align="left">錯誤</th><th class="has-text-align-left" data-align="left">常見原因</th><th class="has-text-align-left" data-align="left">正確處理</th></tr></thead><tbody><tr><td><code>Client with IP address ... is not allowed</code></td><td>App 走公網，SQL firewall 沒放行</td><td>確認 App 是否位於共用 VNet CAE</td></tr><tr><td><code>Login failed</code></td><td>App identity 未建立或沒有 SQL role</td><td>查 App principal ID，由 DB 管理者處理</td></tr><tr><td><code>Cannot resolve host</code></td><td>Private DNS 或 VNet 路徑錯誤</td><td>檢查 PE、DNS zone link、CAE subnet</td></tr><tr><td><code>AuthorizationFailed</code></td><td>CI identity 缺少對應 scope 的 role</td><td>由平台管理者補上 App、ACR 或 CAE role</td></tr></tbody></table></figure>



<h2 class="wp-block-heading">9. 部署後驗證</h2>



<h3 class="wp-block-heading">9.1 查看 App 所在 CAE 與 revision</h3>



<pre class="wp-block-code"><code>az containerapp show `
  --name ca-demoproject-backend-stg `
  --resource-group rg-demoproject-stg-app `
  --query "{environment:properties.managedEnvironmentId,latest:properties.latestRevisionName,ready:properties.latestReadyRevisionName}" `
  -o table
</code></pre>



<pre class="wp-block-code"><code>az containerapp revision list `
  --name ca-demoproject-backend-stg `
  --resource-group rg-demoproject-stg-app `
  --query "&#91;].{name:name,health:properties.healthState,running:properties.runningState,traffic:properties.trafficWeight}" `
  -o table
</code></pre>



<h3 class="wp-block-heading">9.2 查看 logs</h3>



<pre class="wp-block-code"><code>az containerapp logs show `
  --name ca-demoproject-backend-stg `
  --resource-group rg-demoproject-stg-app `
  --type console `
  --tail 100
</code></pre>



<h3 class="wp-block-heading">9.3 驗證 CI 身分</h3>



<pre class="wp-block-code"><code>az ad app federated-credential list \
  --id "$APP_ID" \
  --query "&#91;].{name:name,subject:subject,issuer:issuer,audiences:audiences}" \
  -o table
</code></pre>



<pre class="wp-block-code"><code>az role assignment list \
  --assignee-object-id "$SP_OBJECT_ID" \
  --all \
  --query "&#91;].{role:roleDefinitionName,scope:scope}" \
  -o table
</code></pre>



<p class="wp-block-paragraph">應確認：</p>



<ul class="wp-block-list">
<li>issuer 是 <code>https://token.actions.githubusercontent.com</code>。</li>



<li>audience 是 <code>api://AzureADTokenExchange</code>。</li>



<li>subject 只有指定 repo 與指定 main/environment。</li>



<li>沒有 write identity 的 <code>pull_request</code> subject。</li>



<li>沒有 SQL Resource Group 或共用 CAE Resource Group 的 <code>Contributor</code>。</li>
</ul>



<h2 class="wp-block-heading">10. 首次 bootstrap 與日常部署</h2>



<h3 class="wp-block-heading">10.1 首次 bootstrap</h3>



<p class="wp-block-paragraph">以下工作由平台管理者完成一次：</p>



<ul class="wp-block-list">
<li>建立 ACR、CAE、Container App 或確認資源已存在。</li>



<li>建立 App Registration、Service Principal 與 Federated Credentials。</li>



<li>授予 CI 的 ACR/App/CAE 角色。</li>



<li>啟用 App system-assigned Managed Identity。</li>



<li>授予 App identity <code>AcrPull</code>、Key Vault <code>get/list</code> 與 SQL roles。</li>



<li>設定 Private Endpoint、Private DNS、custom domain 與憑證。</li>
</ul>



<p class="wp-block-paragraph">如果共用 CAE 暫時不存在，不要讓腳本建立沒有 VNet/private endpoint 的替代 環境；應停止流程並通知平台管理者。</p>



<h3 class="wp-block-heading">10.2 日常部署</h3>



<p class="wp-block-paragraph">日常 CI 只需：</p>



<pre class="wp-block-code"><code>main push
  -&gt; GitHub OIDC login
  -&gt; build image
  -&gt; push ACR
  -&gt; update existing Container App
  -&gt; check revision and logs
</code></pre>



<p class="wp-block-paragraph">如果 App 被刪除並重新建立，請重新取得 identity，重新處理 ACR、Key Vault 與 SQL 授權。</p>



<h2 class="wp-block-heading">11. 撤權與團隊交接</h2>



<h3 class="wp-block-heading">11.1 移除某個 repository 的信任</h3>



<pre class="wp-block-code"><code>az ad app federated-credential list \
  --id "$APP_ID" \
  --query "&#91;].{id:id,name:name,subject:subject}" \
  -o table
</code></pre>



<pre class="wp-block-code"><code>az ad app federated-credential delete \
  --id "$APP_ID" \
  --federated-credential-id "&lt;FEDERATED_CREDENTIAL_ID&gt;"
</code></pre>



<h3 class="wp-block-heading">11.2 移除 Azure role</h3>



<pre class="wp-block-code"><code>az role assignment delete \
  --assignee-object-id "$SP_OBJECT_ID" \
  --role "AcrPush" \
  --scope "$ACR_SCOPE"
</code></pre>



<p class="wp-block-paragraph">團隊撤換時同時確認：</p>



<ol class="wp-block-list">
<li>GitHub Environment/repository variables 已移除。</li>



<li>該 repository 的 Federated Credentials 已刪除。</li>



<li>App、ACR、CAE scope 的 role assignments 已移除。</li>



<li>Container App runtime Managed Identity 沒有被誤刪。</li>



<li>Audit log 與變更紀錄已保存。</li>
</ol>



<h2 class="wp-block-heading">12. 新團隊開通檢查表</h2>



<h3 class="wp-block-heading">平台管理者</h3>



<ul class="wp-block-list">
<li>[ ] 所有資源名稱、Subscription、tenant 與 repo 已確認。</li>



<li>[ ] 每個團隊建立獨立 Service Principal。</li>



<li>[ ] 只建立 main 或受保護 Environment 的 Federated Credential。</li>



<li>[ ] 沒有建立 write identity 的 <code>pull_request</code> credential。</li>



<li>[ ] ACR scope 有 <code>AcrPush</code>。</li>



<li>[ ] 既有 App scope 有更新權限。</li>



<li>[ ] 共用 CAE scope 有 <code>Reader</code> + <code>Container Apps Environment Joiner</code>。</li>



<li>[ ] 沒有授予 SQL Resource Group 或共用 CAE Resource Group Contributor。</li>



<li>[ ] App runtime Managed Identity 有 SQL、Key Vault、ACR 權限。</li>



<li>[ ] GitHub Environment 的 reviewers 與 branch restriction 已設定。</li>
</ul>



<h3 class="wp-block-heading">開發團隊</h3>



<ul class="wp-block-list">
<li>[ ] Workflow 有 <code>permissions: id-token: write</code>。</li>



<li>[ ] <code>environment</code> 或 branch subject 與 Azure FIC 完全一致。</li>



<li>[ ] 使用 <code>azure/login@v2</code>，沒有 client secret。</li>



<li>[ ] Docker image 使用不可變的 commit SHA tag。</li>



<li>[ ] Push 到正確 ACR repository。</li>



<li>[ ] 只更新指定 Container App。</li>



<li>[ ] PR workflow 沒有 Azure write token。</li>



<li>[ ] 部署後確認 revision、logs 與功能。</li>
</ul>



<h2 class="wp-block-heading">13. 示範值替換清單</h2>



<p class="wp-block-paragraph">正式使用前，至少替換以下項目：</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th class="has-text-align-left" data-align="left">示範值</th><th class="has-text-align-left" data-align="left">正式值</th></tr></thead><tbody><tr><td><code>DemoProject</code>&nbsp;/&nbsp;<code>demoproject</code></td><td>正式專案名稱</td></tr><tr><td><code>ExampleOrg</code></td><td>GitHub organization</td></tr><tr><td><code>&lt;SUBSCRIPTION_ID&gt;</code></td><td>正式 Subscription ID</td></tr><tr><td><code>&lt;TENANT_ID&gt;</code></td><td>正式 Entra tenant ID</td></tr><tr><td><code>rg-demoproject-*</code></td><td>正式 Resource Groups</td></tr><tr><td><code>acr-demoproject-stg</code></td><td>正式 ACR</td></tr><tr><td><code>cae-demoproject-stg</code></td><td>正式 CAE</td></tr><tr><td><code>ca-demoproject-*-stg</code></td><td>正式 Container Apps</td></tr><tr><td><code>sql-demoproject-stg.database.windows.net</code></td><td>正式 SQL hostname</td></tr><tr><td><code>demoproject-dev</code></td><td>正式 Database</td></tr><tr><td><code>C:\work\demoproject-staging</code></td><td>團隊實際工作目錄</td></tr><tr><td><code>ExampleOrg/demoproject-*</code></td><td>正式 repositories</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">替換後請再次搜尋以下字串，確認沒有殘留示範 placeholder 或錯誤環境：</p>



<pre class="wp-block-code"><code>&lt;SUBSCRIPTION_ID&gt;
&lt;TENANT_ID&gt;
ExampleOrg
demoproject
</code></pre>



<h2 class="wp-block-heading">14. 安全規則</h2>



<ol class="wp-block-list">
<li>不要把 client secret、SQL password、JWT secret 或 storage connection string commit 到 Git。</li>



<li>不要把 secrets 貼到 PR、Issue、聊天工具或 deployment log。</li>



<li>不要為了快速測試把 SQL firewall 設成 <code>0.0.0.0 - 0.0.0.0</code>。</li>



<li>不要把 SQL Resource Group 的 Contributor 當成 SQL data access。</li>



<li>不要把 write-capable Azure identity 授予 <code>pull_request</code>。</li>



<li>不要自行刪除共用 CAE、Private Endpoint、Private DNS 或 SQL Server。</li>



<li>遇到錯誤時，先記錄 repository、workflow run、revision 與錯誤時間， 再請平台管理者檢查權限與網路。</li>
</ol>



<h2 class="wp-block-heading">15. 官方參考文件</h2>



<ul class="wp-block-list">
<li>GitHub OIDC on Azure： <a href="https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-azure">https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-azure</a></li>



<li>GitHub OIDC subject reference： <a href="https://docs.github.com/en/actions/reference/security/oidc">https://docs.github.com/en/actions/reference/security/oidc</a></li>



<li>Azure Login with OIDC： <a href="https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure-openid-connect">https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure-openid-connect</a></li>



<li>Azure Container Apps with GitHub Actions： <a href="https://learn.microsoft.com/en-us/azure/container-apps/github-actions">https://learn.microsoft.com/en-us/azure/container-apps/github-actions</a></li>
</ul>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/09/azure-container-apps-github-oidc/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>GitHub Actions OIDC CI/CD 交接指南</title>
		<link>https://stackoverflow.max-everyday.com/2026/09/azure-github-actions-oidc-ci-cd/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/09/azure-github-actions-oidc-ci-cd/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Sun, 13 Sep 2026 03:36:48 +0000</pubDate>
				<category><![CDATA[Azure 筆記]]></category>
		<category><![CDATA[azure]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8754</guid>

					<description><![CDATA[本文件說明未來新的開發團隊如何在沒有 Azure...]]></description>
										<content:encoded><![CDATA[
<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="572" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_14242048425730948964_clean-1024x572.jpg?v=1789270594" alt="" class="wp-image-8755" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_14242048425730948964_clean-1024x572.jpg?v=1789270594 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_14242048425730948964_clean-600x335.jpg?v=1789270594 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_14242048425730948964_clean-767x428.jpg?v=1789270594 767w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_14242048425730948964_clean.jpg?v=1789270594 1376w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">本文件說明未來新的開發團隊如何在沒有 Azure Owner role 的情況下，使用 GitHub Actions 將 Backend 或 Frontend 部署到專案 Staging。</p>



<p class="wp-block-paragraph">文件目標是讓接手環境管理工作的同仁能快速回答以下問題：</p>



<ul class="wp-block-list">
<li>為什麼不需要把 Azure client secret 放進 GitHub？</li>



<li>哪些工作必須由平台管理者做一次？</li>



<li>開發團隊需要哪些最小 Azure 權限？</li>



<li>GitHub OIDC、Service Principal、Federated Credential、ACR 與 Container Apps 之間如何串接？</li>



<li>為什麼 PR 不可以直接取得可寫入 Staging 的 Azure token？</li>



<li>SQL 權限應該給誰？</li>
</ul>



<p class="wp-block-paragraph">適用環境</p>



<p class="wp-block-paragraph">本文件適用目前的專案 Staging：</p>



<ul class="wp-block-list">
<li>Subscription： DEMO-PoC</li>



<li>App/ACR Resource Group： rg-demo-stg-jpe-001</li>



<li>共用 Container Apps Environment： cae-stg-jpe-001</li>



<li>ACR： acrdemostgjpe001.azurecr.io</li>



<li>SQL Server： sql-stg-jpe-001.database.windows.net</li>



<li>SQL Database： demoproject-dev</li>
</ul>



<p class="wp-block-paragraph">重要</p>



<p class="wp-block-paragraph">目前的 CI 身分由環境管理者建立，開發團隊只負責維護 GitHub Actions。不要要求開發團隊取得 Owner，也不要把 SQL Resource Group 的 Contributor 給開發團隊。</p>



<ol start="1" class="wp-block-list">
<li>先看整體流程</li>
</ol>



<pre class="wp-block-code"><code>GitHub push to main
        |
        | GitHub 發出短期 OIDC token
        v
GitHub Actions
        |
        | azure/login@v2
        v
Microsoft Entra App / Service Principal
        |
        | Federated Credential 比對 repo + branch/environment
        | Azure RBAC 比對允許的 scope
        v
Azure Container Registry (AcrPush)
        |
        | push Docker image
        v
Azure Container App
        |
        | 使用 App 自己的 Managed Identity
        v
SQL Private Endpoint -> demoproject-dev</code></pre>



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



<p class="wp-block-paragraph">這裡有兩個不同的身分，聲明如下：</p>



<ul class="wp-block-list">
<li>GitHub Actions Service Principal：用途為 Build、push Image、更新 Container App。是否應有 SQL data access：否。</li>



<li>Backend Container App Managed Identity：用途為執行中的 Backend 連接 SQL、ACR、Key Vault。是否應有 SQL data access：是，僅限該 App。</li>
</ul>



<p class="wp-block-paragraph">GitHub Actions 的 OIDC Service Principal 只負責部署。它不應該被加入 demoproject-dev 的 SQL roles，也不應該讀取應用程式 secrets。</p>



<ol start="2" class="wp-block-list">
<li>為什麼使用 OIDC</li>
</ol>



<p class="wp-block-paragraph">傳統做法是把 AZURE_CLIENT_SECRET 存在 GitHub Actions Secret。這會產生長期憑證管理問題：</p>



<ul class="wp-block-list">
<li>secret 可能被誤印到 log。</li>



<li>secret 可能忘記輪替。</li>



<li>離職或團隊更換時，需要追查 secret 被放在哪裡。</li>
</ul>



<p class="wp-block-paragraph">OIDC 的做法是：</p>



<ol start="1" class="wp-block-list">
<li>GitHub Actions 向 GitHub OIDC provider 取得短期 token。</li>



<li>Azure 驗證 token 的 issuer、subject 與 audience。</li>



<li>只有符合 Federated Credential 條件的 workflow 才能換取 Azure access token。</li>



<li>Azure RBAC 再決定這個 Service Principal 可以操作哪些資源。</li>
</ol>



<p class="wp-block-paragraph">因此 OIDC 不等於「自動擁有 Azure 權限」：</p>



<ul class="wp-block-list">
<li>Federated Credential：決定「哪個 GitHub workflow 可以登入」。</li>



<li>Azure RBAC：決定「登入後可以做什麼」。</li>
</ul>



<ol start="3" class="wp-block-list">
<li>本次已建立的 CI 身分</li>
</ol>



<p class="wp-block-paragraph">本次已建立以下 CI Service Principal：</p>



<p class="wp-block-paragraph">Display name: demoproject-staging-ci</p>



<p class="wp-block-paragraph">Client ID: 188a7d0a-30d5-4a0d-9b04-3e118d3d46e7</p>



<p class="wp-block-paragraph">Object ID: 33ec7c42-e5b5-443c-aae5-e97e1e056d10</p>



<p class="wp-block-paragraph">Tenant ID: 7ef65350-5b77-4958-aca5-0ccadb6bd0b7</p>



<p class="wp-block-paragraph">沒有建立 client secret。</p>



<p class="wp-block-paragraph">3.1 目前已授予的角色</p>



<ul class="wp-block-list">
<li>Scope: rg-demo-stg-jpe-001 | Role: Contributor | 目的: 目前 CI 更新 App/ACR 的過渡權限</li>



<li>Scope: acrdemostgjpe001 | Role: AcrPush | 目的: 允許 Docker push 到 ACR</li>



<li>Scope: cae-stg-jpe-001 | Role: Reader | 目的: 讀取共用 CAE 設定</li>



<li>Scope: cae-stg-jpe-001 | Role: Container Apps Environment Joiner | 目的: 允許使用共用 CAE；只有 Microsoft.App/managedEnvironments/join/action</li>
</ul>



<p class="wp-block-paragraph">目前沒有授予：</p>



<ul class="wp-block-list">
<li>rg-cae-stg-jpe-001 的 Contributor。</li>



<li>rg-data-stg-jpe-001 的任何角色。</li>



<li>demoproject-dev 的 SQL role。</li>



<li>Key Vault data-plane secrets 讀取權限。</li>
</ul>



<p class="wp-block-paragraph">rg-demo-stg-jpe-001 的 Contributor 是為了讓現有流程先能運作的過渡方案。未來新增團隊時，建議改成各 App resource scope 的 Contributor 或專用 custom role，不要複製成每個團隊都能管理整個 RG。</p>



<p class="wp-block-paragraph">3.2 目前已信任的 GitHub repositories</p>



<p class="wp-block-paragraph">目前只建立以下兩個 main branch Federated Credentials：</p>



<p class="wp-block-paragraph">repo:NYCUITSC/demoproject-backend:ref:refs/heads/main</p>



<p class="wp-block-paragraph">repo:NYCUITSC/demoproject-frontend:ref:refs/heads/main</p>



<p class="wp-block-paragraph">刻意沒有建立：</p>



<ul class="wp-block-list">
<li>pull_request credential。</li>



<li>NYCUITSC/demoproject-api credential。</li>
</ul>



<p class="wp-block-paragraph">demoproject-api 目前是 Frontend 用來產生 SDK 的 dependency，不是 Azure deployment repository。除非它未來真的擁有部署 job，否則不要給它 Azure write identity。</p>



<ol start="4" class="wp-block-list">
<li>新團隊的責任分工</li>
</ol>



<p class="wp-block-paragraph">4.1 平台/環境管理者做一次</p>



<p class="wp-block-paragraph">平台管理者需要：</p>



<ol start="1" class="wp-block-list">
<li>建立該團隊專用的 App Registration 與 Service Principal。</li>



<li>建立只允許指定 repo、branch 或 GitHub Environment 的 Federated Credentials。</li>



<li>在正確的 ACR scope 授予 AcrPush。</li>



<li>在指定 Container App scope 授予更新權限。</li>



<li>在共用 CAE 授予 Reader 與 Container Apps Environment Joiner。</li>



<li>確認 Container App 的 Managed Identity 已具備 ACR、Key Vault 與 SQL 權限。</li>



<li>確認 App 位於正確的共用 CAE，而不是沒有 VNet/private DNS 的獨立 CAE。</li>



<li>將 Client ID、Tenant ID、Subscription ID 提供給團隊設定 GitHub Variables。</li>
</ol>



<p class="wp-block-paragraph">4.2 開發團隊負責</p>



<p class="wp-block-paragraph">開發團隊只需要：</p>



<ol start="1" class="wp-block-list">
<li>維護 GitHub Actions workflow。</li>



<li>在 main 或受保護的 staging Environment 執行 deploy。</li>



<li>Build Docker image。</li>



<li>Push image 到 ACR。</li>



<li>更新既有 Container App 的 image。</li>



<li>查看 revision 與 logs。</li>
</ol>



<p class="wp-block-paragraph">開發團隊不應該：</p>



<ul class="wp-block-list">
<li>建立或刪除 Service Principal。</li>



<li>修改 SQL firewall、Private Endpoint 或 Private DNS。</li>



<li>將 App secret 寫進 GitHub workflow。</li>



<li>執行 SQL CREATE USER。</li>



<li>修改共用 CAE 的 VNet 設定。</li>
</ul>



<ol start="5" class="wp-block-list">
<li>未來新增團隊的標準開通流程</li>
</ol>



<p class="wp-block-paragraph">以下流程由平台管理者執行。每個團隊建議有自己的 Service Principal，不要讓所有團隊共用一個可寫入 Staging 的 identity。</p>



<p class="wp-block-paragraph">5.1 設定資源變數</p>



<p class="wp-block-paragraph">以下是目前資源的範例。新增團隊時，App name 與 repo 名稱應換成新團隊實際值。</p>



<p class="wp-block-paragraph">set -euo pipefail</p>



<p class="wp-block-paragraph">SUB_ID=&#8221;56b72537-d985-4530-88f3-b6ed07e71c67&#8243;</p>



<p class="wp-block-paragraph">APP_RG=&#8221;rg-demo-stg-jpe-001&#8243;</p>



<p class="wp-block-paragraph">CAE_RG=&#8221;rg-cae-stg-jpe-001&#8243;</p>



<p class="wp-block-paragraph">CAE_NAME=&#8221;cae-stg-jpe-001&#8243;</p>



<p class="wp-block-paragraph">ACR_RG=&#8221;rg-demo-stg-jpe-001&#8243;</p>



<p class="wp-block-paragraph">ACR_NAME=&#8221;acrdemostgjpe001&#8243;</p>



<h1 class="wp-block-heading">每個團隊使用不同名稱，例如：</h1>



<h1 class="wp-block-heading">demoproject-sdc-team-2027-ci</h1>



<p class="wp-block-paragraph">SP_NAME=&#8221;demoproject-new-team-ci&#8221;</p>



<p class="wp-block-paragraph">BACKEND_CA_NAME=&#8221;ca-demo-backend-dev-jpe-001&#8243;</p>



<p class="wp-block-paragraph">FRONTEND_CA_NAME=&#8221;ca-demo-frontend-dev-jpe-001&#8243;</p>



<p class="wp-block-paragraph">不要把 SQL Resource Group 放進 CI role scope：</p>



<p class="wp-block-paragraph">rg-data-stg-jpe-001</p>



<p class="wp-block-paragraph">5.2 建立 App Registration 與 Service Principal</p>



<p class="wp-block-paragraph">執行者必須具備 Entra App 建立權限。建立 Azure role assignment 另外需要 Owner 或 User Access Administrator；新加入的開發團隊不需要具備這些管理權限。</p>



<p class="wp-block-paragraph">APP_ID=&#8221;$(az ad app create</p>



<p class="wp-block-paragraph">&#8211;display-name &#8220;$SP_NAME&#8221; \ &#8211;query appId -o tsv)&#8221; SP_OBJECT_ID=&#8221;$(az ad sp create</p>



<p class="wp-block-paragraph">&#8211;id &#8220;$APP_ID&#8221;</p>



<p class="wp-block-paragraph">&#8211;query id -o tsv)&#8221;</p>



<p class="wp-block-paragraph">echo &#8220;clientId: $APP_ID&#8221;</p>



<p class="wp-block-paragraph">echo &#8220;servicePrincipalObjectId: $SP_OBJECT_ID&#8221;</p>



<p class="wp-block-paragraph">不要用以下方式反查 App ID：</p>



<p class="wp-block-paragraph">az ad app list &#8211;display-name &#8220;$SP_NAME&#8221;</p>



<p class="wp-block-paragraph">因為 display name 可能重複，會拿到錯誤的 App。</p>



<p class="wp-block-paragraph">5.3 授予 CI 最小必要角色</p>



<p class="wp-block-paragraph">先取得 resource IDs：</p>



<p class="wp-block-paragraph">APP_SCOPE=&#8221;/subscriptions/${SUB_ID}/resourceGroups/${APP_RG}&#8221;</p>



<p class="wp-block-paragraph">CAE_SCOPE=&#8221;$(az containerapp env show</p>



<p class="wp-block-paragraph">&#8211;subscription &#8220;$SUB_ID&#8221;</p>



<p class="wp-block-paragraph">&#8211;resource-group &#8220;$CAE_RG&#8221;</p>



<p class="wp-block-paragraph">&#8211;name &#8220;$CAE_NAME&#8221; \ &#8211;query id -o tsv)&#8221; ACR_SCOPE=&#8221;$(az acr show</p>



<p class="wp-block-paragraph">&#8211;subscription &#8220;$SUB_ID&#8221;</p>



<p class="wp-block-paragraph">&#8211;resource-group &#8220;$ACR_RG&#8221;</p>



<p class="wp-block-paragraph">&#8211;name &#8220;$ACR_NAME&#8221; \ &#8211;query id -o tsv)&#8221; BACKEND_SCOPE=&#8221;$(az containerapp show</p>



<p class="wp-block-paragraph">&#8211;subscription &#8220;$SUB_ID&#8221;</p>



<p class="wp-block-paragraph">&#8211;resource-group &#8220;$APP_RG&#8221;</p>



<p class="wp-block-paragraph">&#8211;name &#8220;$BACKEND_CA_NAME&#8221; \ &#8211;query id -o tsv)&#8221; FRONTEND_SCOPE=&#8221;$(az containerapp show</p>



<p class="wp-block-paragraph">&#8211;subscription &#8220;$SUB_ID&#8221;</p>



<p class="wp-block-paragraph">&#8211;resource-group &#8220;$APP_RG&#8221;</p>



<p class="wp-block-paragraph">&#8211;name &#8220;$FRONTEND_CA_NAME&#8221;</p>



<p class="wp-block-paragraph">&#8211;query id -o tsv)&#8221;</p>



<p class="wp-block-paragraph">長期建議只給既有 App scope 的 Contributor：</p>



<p class="wp-block-paragraph">az role assignment create</p>



<p class="wp-block-paragraph">&#8211;subscription &#8220;$SUB_ID&#8221;</p>



<p class="wp-block-paragraph">&#8211;assignee-object-id &#8220;$SP_OBJECT_ID&#8221;</p>



<p class="wp-block-paragraph">&#8211;assignee-principal-type ServicePrincipal</p>



<p class="wp-block-paragraph">&#8211;role &#8220;Contributor&#8221;</p>



<p class="wp-block-paragraph">&#8211;scope &#8220;$BACKEND_SCOPE&#8221;</p>



<p class="wp-block-paragraph">az role assignment create</p>



<p class="wp-block-paragraph">&#8211;subscription &#8220;$SUB_ID&#8221;</p>



<p class="wp-block-paragraph">&#8211;assignee-object-id &#8220;$SP_OBJECT_ID&#8221;</p>



<p class="wp-block-paragraph">&#8211;assignee-principal-type ServicePrincipal</p>



<p class="wp-block-paragraph">&#8211;role &#8220;Contributor&#8221;</p>



<p class="wp-block-paragraph">&#8211;scope &#8220;$FRONTEND_SCOPE&#8221;</p>



<p class="wp-block-paragraph">CI push ACR 需要另外的 data-plane role：</p>



<p class="wp-block-paragraph">az role assignment create</p>



<p class="wp-block-paragraph">&#8211;subscription &#8220;$SUB_ID&#8221;</p>



<p class="wp-block-paragraph">&#8211;assignee-object-id &#8220;$SP_OBJECT_ID&#8221;</p>



<p class="wp-block-paragraph">&#8211;assignee-principal-type ServicePrincipal</p>



<p class="wp-block-paragraph">&#8211;role &#8220;AcrPush&#8221;</p>



<p class="wp-block-paragraph">&#8211;scope &#8220;$ACR_SCOPE&#8221;</p>



<p class="wp-block-paragraph">使用共用 CAE 需要：</p>



<p class="wp-block-paragraph">az role assignment create</p>



<p class="wp-block-paragraph">&#8211;subscription &#8220;$SUB_ID&#8221;</p>



<p class="wp-block-paragraph">&#8211;assignee-object-id &#8220;$SP_OBJECT_ID&#8221;</p>



<p class="wp-block-paragraph">&#8211;assignee-principal-type ServicePrincipal</p>



<p class="wp-block-paragraph">&#8211;role &#8220;Reader&#8221;</p>



<p class="wp-block-paragraph">&#8211;scope &#8220;$CAE_SCOPE&#8221;</p>



<p class="wp-block-paragraph">az role assignment create</p>



<p class="wp-block-paragraph">&#8211;subscription &#8220;$SUB_ID&#8221;</p>



<p class="wp-block-paragraph">&#8211;assignee-object-id &#8220;$SP_OBJECT_ID&#8221;</p>



<p class="wp-block-paragraph">&#8211;assignee-principal-type ServicePrincipal</p>



<p class="wp-block-paragraph">&#8211;role &#8220;Container Apps Environment Joiner&#8221;</p>



<p class="wp-block-paragraph">&#8211;scope &#8220;$CAE_SCOPE&#8221;</p>



<p class="wp-block-paragraph">若團隊需要由 CI 建立全新的 Container App，而不是更新既有 App，先由平台管理者評估是否真的需要 RG Contributor。可以先由管理者建立 App，再把後續 CI 限制為既有 App scope。</p>



<p class="wp-block-paragraph">5.4 建立 Federated Credential</p>



<p class="wp-block-paragraph">方案 A：只允許 main branch</p>



<p class="wp-block-paragraph">適合簡單的單一 staging deploy workflow：</p>



<p class="wp-block-paragraph">REPO=&#8221;NEW_ORG/NEW_REPO&#8221;</p>



<p class="wp-block-paragraph">SAFE_NAME=&#8221;${REPO//\//-}-main&#8221; SUBJECT=&#8221;repo:${REPO}:ref:refs/heads/main&#8221;</p>



<p class="wp-block-paragraph">PAYLOAD=&#8221;$(jq -n</p>



<p class="wp-block-paragraph">&#8211;arg name &#8220;$SAFE_NAME&#8221;</p>



<p class="wp-block-paragraph">&#8211;arg subject &#8220;$SUBJECT&#8221;</p>



<p class="wp-block-paragraph">&#8216;{name: $name, issuer: &#8220;<a target="_blank" rel="noopener" href="https://www.google.com/search?q=https://token.actions.githubusercontent.com">https://token.actions.githubusercontent.com</a></p>



<p class="wp-block-paragraph">&#8220;, subject: $subject, audiences: [&#8220;api://AzureADTokenExchange&#8221;] }&#8217;)&#8221;</p>



<p class="wp-block-paragraph">az ad app federated-credential create</p>



<p class="wp-block-paragraph">&#8211;id &#8220;$APP_ID&#8221;</p>



<p class="wp-block-paragraph">&#8211;parameters &#8220;$PAYLOAD&#8221;</p>



<p class="wp-block-paragraph">方案 B：使用受保護的 GitHub Environment（推薦新團隊）</p>



<p class="wp-block-paragraph">在 GitHub repository 建立 staging Environment，設定：</p>



<ul class="wp-block-list">
<li>Required reviewers。</li>



<li>只允許 main branch 或指定 tag。</li>



<li>將 Azure identifiers 放在 Environment variables。</li>
</ul>



<p class="wp-block-paragraph">Federated Credential 的 subject 改為：</p>



<p class="wp-block-paragraph">repo:NEW_ORG/NEW_REPO:environment:staging</p>



<p class="wp-block-paragraph">Workflow 的 deploy job 必須設定：</p>



<p class="wp-block-paragraph">environment: staging</p>



<p class="wp-block-paragraph">Branch subject 與 Environment subject 不要混用。Azure 會比對完整的 sub claim，字串不完全相同就會登入失敗。</p>



<p class="wp-block-paragraph">絕對不要把 write credential 給 pull_request</p>



<p class="wp-block-paragraph">以下 subject 不可綁定到具有 Contributor、AcrPush 或 App write 的 identity：</p>



<p class="wp-block-paragraph">repo:NEW_ORG/NEW_REPO:pull_request</p>



<p class="wp-block-paragraph">PR 的程式碼尚未審核，任何可以修改 workflow 的 PR 都可能嘗試呼叫 Azure CLI。PR workflow 應只執行 lint/test/build；如果真的需要查詢 Azure，另建只有 Reader 的 CI identity。</p>



<p class="wp-block-paragraph">5.5 提供 GitHub Variables</p>



<p class="wp-block-paragraph">提供給團隊的只有識別資訊：</p>



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



<p class="wp-block-paragraph">AZURE_TENANT_ID = 7ef65350-5b77-4958-aca5-0ccadb6bd0b7</p>



<p class="wp-block-paragraph">AZURE_SUBSCRIPTION_ID = 56b72537-d985-4530-88f3-b6ed07e71c67</p>



<p class="wp-block-paragraph">不需要也不應該提供：</p>



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



<p class="wp-block-paragraph">Client ID、Tenant ID、Subscription ID 本身不是登入密碼；真正的信任條件由 Azure Federated Credential 與 GitHub workflow permission 控制。</p>



<ol start="6" class="wp-block-list">
<li>GitHub Actions Workflow 範本</li>
</ol>



<p class="wp-block-paragraph">以下範例使用「受保護 staging Environment」方案。若使用目前既有的 main branch credential，請移除 environment: staging，或先建立相符的 Environment Federated Credential。</p>



<p class="wp-block-paragraph">name: Deploy Backend to Azure Staging</p>



<p class="wp-block-paragraph">on:</p>



<p class="wp-block-paragraph">push:</p>



<p class="wp-block-paragraph">branches:</p>



<p class="wp-block-paragraph">&#8211; main</p>



<p class="wp-block-paragraph">permissions:</p>



<p class="wp-block-paragraph">contents: read</p>



<p class="wp-block-paragraph">id-token: write</p>



<p class="wp-block-paragraph">jobs:</p>



<p class="wp-block-paragraph">deploy:</p>



<p class="wp-block-paragraph">runs-on: ubuntu-latest</p>



<p class="wp-block-paragraph">environment: staging</p>



<p class="wp-block-paragraph">steps:</p>



<p class="wp-block-paragraph">&#8211; name: Checkout</p>



<p class="wp-block-paragraph">uses: actions/checkout@v4</p>



<p class="wp-block-paragraph">&#8211; name: Login to Azure with OIDC</p>



<p class="wp-block-paragraph">uses: azure/login@v2</p>



<p class="wp-block-paragraph">with:</p>



<p class="wp-block-paragraph">client-id: ${{ vars.AZURE_CLIENT_ID }}</p>



<p class="wp-block-paragraph">tenant-id: ${{ vars.AZURE_TENANT_ID }}</p>



<p class="wp-block-paragraph">subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }} &#8211; name: Login to ACR run: az acr login &#8211;name acrdemostgjpe001 &#8211; name: Build and push image env: IMAGE: acrdemostgjpe001.azurecr.io/demo-dev-backend:${{ github.sha }}</p>



<p class="wp-block-paragraph">run: |</p>



<p class="wp-block-paragraph">docker build -t &#8220;$IMAGE&#8221; .</p>



<p class="wp-block-paragraph">docker push &#8220;$IMAGE&#8221; &#8211; name: Update existing Container App env: IMAGE: acrdemostgjpe001.azurecr.io/demo-dev-backend:${{ github.sha }}</p>



<p class="wp-block-paragraph">run: |</p>



<p class="wp-block-paragraph">az containerapp update</p>



<p class="wp-block-paragraph">&#8211;resource-group rg-demo-stg-jpe-001</p>



<p class="wp-block-paragraph">&#8211;name ca-demo-backend-dev-jpe-001</p>



<p class="wp-block-paragraph">&#8211;image &#8220;$IMAGE&#8221;</p>



<p class="wp-block-paragraph">Frontend 只需要替換 Image repository 與 Container App name。</p>



<p class="wp-block-paragraph">6.1 不要在 CI 直接執行完整 deploy.dev.ps1</p>



<p class="wp-block-paragraph">deploy.dev.ps1 適合管理者或本機部署流程，不適合直接放入 GitHub Actions，原因包括：</p>



<ul class="wp-block-list">
<li>會要求 deploy.secrets.ps1。</li>



<li>會將應用程式 secrets 套用到 Container App。</li>



<li>會包含 Managed Identity、Key Vault policy、SQL 授權等 bootstrap 工作。</li>



<li>可能嘗試建立或修改基礎資源。</li>
</ul>



<p class="wp-block-paragraph">CI 的正常流程應是：</p>



<p class="wp-block-paragraph">OIDC login -&gt; docker build -&gt; ACR push -&gt; containerapp update &#8211;image</p>



<p class="wp-block-paragraph">既有的環境變數與 secrets 由平台管理者預先設定；App runtime 使用自己的 Managed Identity 連接 SQL 與 Key Vault。</p>



<p class="wp-block-paragraph">6.2 PR Workflow</p>



<p class="wp-block-paragraph">PR workflow 不應取得 Azure write token：</p>



<p class="wp-block-paragraph">on:</p>



<p class="wp-block-paragraph">pull_request:</p>



<p class="wp-block-paragraph">branches:</p>



<p class="wp-block-paragraph">&#8211; main</p>



<p class="wp-block-paragraph">permissions:</p>



<p class="wp-block-paragraph">contents: read</p>



<p class="wp-block-paragraph">PR 可執行 lint、unit test、build 或不涉及 Staging 的 container build，但不要：</p>



<ul class="wp-block-list">
<li>id-token: write。</li>



<li>azure/login。</li>



<li>Push Staging ACR。</li>



<li>Update 或 delete Container App。</li>
</ul>



<ol start="7" class="wp-block-list">
<li>目前既有 Workflow 的注意事項</li>
</ol>



<p class="wp-block-paragraph">目前 demoproject-backend/.github/workflows/dev.yaml 與 demoproject-frontend/.github/workflows/dev.yaml 的 Dev 流程原本是：</p>



<ol start="1" class="wp-block-list">
<li>Build/test。</li>



<li>Push 到 harbor.sdc.nycu.club。</li>



<li>呼叫 n8n deployment webhook。</li>
</ol>



<p class="wp-block-paragraph">建立 Azure OIDC identity 不會自動改變這些 workflow。若要切換到 Azure Container Apps：</p>



<ol start="1" class="wp-block-list">
<li>由團隊決定是否停止 Harbor+n8n deployment。</li>



<li>將 Azure login、ACR push、Container App update 加入新的 deploy job。</li>



<li>確認新 workflow 的 subject 與 Federated Credential 完全一致。</li>



<li>先在 staging Environment 做一次受審核測試。</li>



<li>確認 revision、logs、API version 與 Frontend URL 後，再移除舊流程。</li>
</ol>



<p class="wp-block-paragraph">不要讓 Harbor+n8n 與 Azure CI 同時部署同一個功能環境，否則最後一個完成的 pipeline 可能覆蓋前一個結果。</p>



<ol start="8" class="wp-block-list">
<li>SQL 與網路權限不要放到 CI</li>
</ol>



<p class="wp-block-paragraph">8.1 CI identity 不需要 SQL role</p>



<p class="wp-block-paragraph">CI Service Principal 只更新 Container App image。SQL runtime access 由 Backend Container App 的 system-assigned Managed Identity 處理：</p>



<p class="wp-block-paragraph">GitHub CI identity -&gt; ACR push -&gt; Container App update</p>



<p class="wp-block-paragraph">Backend App Managed Identity -&gt; Private Endpoint -&gt; demoproject-dev</p>



<p class="wp-block-paragraph">如果 App 被刪除後重新建立，system-assigned identity 可能改變。平台/DB 管理者需要重新確認：</p>



<ul class="wp-block-list">
<li>AcrPull。</li>



<li>Key Vault secret get/list。</li>



<li>demoproject-dev contained user。</li>



<li>db_datareader、db_datawriter、必要時 db_ddladmin。</li>
</ul>



<p class="wp-block-paragraph">8.2 CI identity 不需要修改 SQL firewall</p>



<p class="wp-block-paragraph">目前 SQL 使用 Private Endpoint 與 Private DNS。應用程式應部署到有正確 VNet 路徑的共用 cae-stg-jpe-001。</p>



<p class="wp-block-paragraph">遇到以下錯誤時，不要直接把 GitHub Actions Service Principal 加到 SQL 或把 firewall 設成 0.0.0.0：</p>



<p class="wp-block-paragraph">Client with IP address &#8216;&#8230;&#8217; is not allowed to access the server</p>



<p class="wp-block-paragraph">這通常代表 Container App 位於沒有 VNet/private DNS 的 CAE，或 hostname 解析走到公網。請先檢查 App 的 managedEnvironmentId。</p>



<ol start="9" class="wp-block-list">
<li>驗證清單</li>
</ol>



<p class="wp-block-paragraph">9.1 管理者驗證 App 與 Federated Credentials</p>



<pre class="wp-block-code"><code>az ad app federated-credential list
--id "$APP_ID"
--query "&#91;].{name:name,subject:subject,issuer:issuer,audiences:audiences}"
-o table</code></pre>



<p class="wp-block-paragraph">應確認：</p>



<ul class="wp-block-list">
<li>issuer 是 <a href="https://www.google.com/search?q=https://token.actions.githubusercontent.com" target="_blank" rel="noopener">https://token.actions.githubusercontent.com</a>。</li>



<li>audience 是 api://AzureADTokenExchange。</li>



<li>subject 只有指定 repo 與指定 main/environment。</li>



<li>沒有 write identity 的 pull_request subject。</li>
</ul>



<p class="wp-block-paragraph">9.2 驗證 Azure RBAC</p>



<pre class="wp-block-code"><code>az role assignment list
--assignee-object-id "$SP_OBJECT_ID"
--all
--query "&#91;].{role:roleDefinitionName,scope:scope}"
-o table</code></pre>



<p class="wp-block-paragraph">不應出現：</p>



<ul class="wp-block-list">
<li>Contributor at rg-cae-stg-jpe-001。</li>



<li>任何 role at rg-data-stg-jpe-001。</li>



<li>不必要的 SQL 或 Key Vault data-plane role。</li>
</ul>



<p class="wp-block-paragraph">9.3 團隊驗證 workflow</p>



<p class="wp-block-paragraph">部署成功後確認：</p>



<pre class="wp-block-code"><code>az acr repository show-tags
--name acrdemostgjpe001
--repository demo-dev-backend
--orderby time_desc
--top 5
-o table

az containerapp revision list
--resource-group rg-demo-stg-jpe-001
--name ca-demo-backend-dev-jpe-001
--query "&#91;].{name:name,health:properties.healthState,running:properties.runningState,traffic:properties.trafficWeight}"
-o table

az containerapp logs show
--resource-group rg-demo-stg-jpe-001
--name ca-demo-backend-dev-jpe-001
--type console
--tail 100</code></pre>



<ol start="10" class="wp-block-list">
<li>常見錯誤</li>
</ol>



<ul class="wp-block-list">
<li>AADSTS70021: No matching federated identity record found | 原因：subject、branch/environment、issuer 或 audience 不一致 | 處理方式：對照 GitHub workflow 的 trigger 與 environment，列出 FIC 逐字比較</li>



<li>AuthorizationFailed | 原因：CI identity 沒有對應 scope 的 role | 處理方式：由平台管理者補上 App scope、ACR 或 CAE join role</li>



<li>denied: requested access to the resource is denied | 原因：ACR 沒有 AcrPush | 處理方式：在 ACR scope 加 AcrPush，不是只加 Contributor</li>



<li>Container App cannot join environment | 原因：缺 Microsoft.App/managedEnvironments/join/action | 處理方式：在共用 CAE scope 加 Container Apps Environment Joiner</li>



<li>SQL Client with IP &#8230; not allowed | 原因：App 走公網，沒有走 Private Endpoint | 處理方式：檢查 App 是否位於共用 CAE，不要先加大量 firewall IP</li>



<li>SQL Login failed | 原因：App Managed Identity 未建立 contained user 或 SQL role | 處理方式：查 App identity，由 DB 管理者處理，不是修改 CI role</li>



<li>Image push 後沒有新 revision | 原因：workflow push 到錯誤 ACR/tag，或沒有執行 az containerapp update | 處理方式：檢查 image URI、ACR repository、revision list 與 workflow log</li>



<li>Workflow 使用 tag 觸發但 Azure login 失敗 | 原因：只有 main branch subject，沒有符合 tag/environment 的 FIC | 處理方式：改成只由 main deploy，或由管理者建立精準的 tag/environment FIC</li>
</ul>



<ol start="11" class="wp-block-list">
<li>團隊撤換與緊急撤權</li>
</ol>



<p class="wp-block-paragraph">OIDC 不使用長期 client secret，因此撤權主要是刪除 Federated Credential 或 Azure role assignment。</p>



<p class="wp-block-paragraph">移除某個 repo 的信任</p>



<p class="wp-block-paragraph">先列出 credential ID：</p>



<pre class="wp-block-code"><code>az ad app federated-credential list
--id "$APP_ID"
--query "&#91;].{id:id,name:name,subject:subject}"
-o table</code></pre>



<p class="wp-block-paragraph">再刪除指定 credential：</p>



<pre class="wp-block-code"><code>az ad app federated-credential delete
--id "$APP_ID"
--federated-credential-id ""</code></pre>



<p class="wp-block-paragraph">移除 Azure role</p>



<pre class="wp-block-code"><code>az role assignment delete
--assignee-object-id "$SP_OBJECT_ID"
--role "AcrPush"
--scope "$ACR_SCOPE"</code></pre>



<p class="wp-block-paragraph">撤換整個團隊時，應同時：</p>



<ol start="1" class="wp-block-list">
<li>移除 GitHub Environment/repository variables。</li>



<li>刪除該 repo 的 Federated Credentials。</li>



<li>移除 App、ACR、CAE scope 的 role assignments。</li>



<li>確認 Container App runtime Managed Identity 不被誤刪。</li>



<li>保留 audit log 與變更紀錄。</li>



<li>新團隊開通檢查表</li>
</ol>



<p class="wp-block-paragraph">平台管理者</p>



<ul class="wp-block-list">
<li>確認目標 repo 與實際部署的 Backend/Frontend。</li>



<li>每個團隊建立獨立 Service Principal。</li>



<li>只建立 main 或受保護 Environment 的 FIC。</li>



<li>不建立 write identity 的 pull_request FIC。</li>



<li>ACR scope 有 AcrPush。</li>



<li>既有 App scope 有更新權限。</li>



<li>共用 CAE scope 有 Reader + Container Apps Environment Joiner。</li>



<li>沒有授予 SQL Resource Group 或共用 CAE Resource Group Contributor。</li>



<li>確認 App runtime Managed Identity 的 SQL/Key Vault/ACR 權限。</li>



<li>將三個 Azure identifiers 提供給 GitHub Environment variables。</li>
</ul>



<p class="wp-block-paragraph">開發團隊</p>



<ul class="wp-block-list">
<li>Workflow 有 permissions: id-token: write。</li>



<li>Deploy job 的 environment 或 branch subject 與 Azure FIC 完全一致。</li>



<li>使用 azure/login@v2，沒有 client secret。</li>



<li>Docker image 使用不可變的 commit SHA tag。</li>



<li>Push 到正確的 ACR repository。</li>



<li>只更新指定 Container App，不修改 CAE/VNet/SQL。</li>



<li>PR workflow 沒有 Azure write token。</li>



<li>部署後確認 revision、logs 與功能。</li>
</ul>



<ol start="13" class="wp-block-list">
<li>相關文件</li>
</ol>



<ul class="wp-block-list">
<li>Staging 新手指南： STAGING-ONBOARDING-ZH-TW.md</li>



<li>Dev 部署腳本： deploy.dev.ps1</li>



<li>Backend 權限說明： demoproject-backend\docs\dev-deployment-permissions.md</li>
</ul>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/09/azure-github-actions-oidc-ci-cd/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>😱 救命！硬碟又被吃光了！用 Python 打造「自動刪除備份檔」</title>
		<link>https://stackoverflow.max-everyday.com/2026/09/clean_bak/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/09/clean_bak/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Tue, 08 Sep 2026 02:12:45 +0000</pubDate>
				<category><![CDATA[Python筆記]]></category>
		<category><![CDATA[Python]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8736</guid>

					<description><![CDATA[你是否有過這種經歷？ 某個平靜的清晨，你正端著咖...]]></description>
										<content:encoded><![CDATA[
<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="1024" height="572" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_16295216377751810940.jpg?v=1788832742" alt="" class="wp-image-8737" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_16295216377751810940.jpg?v=1788832742 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_16295216377751810940-600x335.jpg?v=1788832742 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/09/watermarked_img_16295216377751810940-768x429.jpg?v=1788832742 768w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">你是否有過這種經歷？</p>



<p class="wp-block-paragraph">某個平靜的清晨，你正端著咖啡準備開啟美好的一天，突然監報系統傳來刺眼的紅字警告：</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><strong>[CRITICAL] Server Disk Space Usage: 99.8% !!!</strong></p>
</blockquote>



<p class="wp-block-paragraph">你心裡一驚，打開伺服器一查，罪魁禍首不出所料——又是那些默默囤積、永無止境的 DB 備份檔（<code>.bak</code>）！</p>



<p class="wp-block-paragraph">為了防止「伺服器暴斃」與「半夜被叫醒維護」的悲劇發生，今天我們就來聊聊怎麼用 Python 寫出一個聰明、安全又省心的 <strong>「.bak 檔案自動清理神器」</strong>！</p>



<h2 class="wp-block-heading"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4a3.png" alt="💣" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 備份檔的「膨脹魔咒」：為什麼硬碟總是不夠用？</h2>



<p class="wp-block-paragraph">在 IT 與開發者的日常裡，資料庫備份是我們的生命線。「每天備份一次」是基本常識，甚至有些高頻系統每小時都在備份。</p>



<p class="wp-block-paragraph">一個每日 1GB 的 DB 備份檔，聽起來好像還好對吧？</p>



<ul class="wp-block-list">
<li>1 天 = 1 GB</li>



<li>1 個月 = 30 GB</li>



<li>半年 = <strong>180 GB！</strong></li>



<li>1 年 = <strong>365 GB！</strong></li>
</ul>



<p class="wp-block-paragraph">如果你有好幾個資料庫，再加上歷史累積，硬碟空間很快就會像連假期間的高速公路一樣——<strong>卡到爆！</strong></p>



<p class="wp-block-paragraph">最可怕的是，許多系統管理者往往都是在<strong>硬碟被塞爆、服務掛掉的那一刻</strong>，才驚覺原來半年前的備份檔還躺在那裡吸血。</p>



<h2 class="wp-block-heading"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f9b8-200d-2642-fe0f.png" alt="🦸‍♂️" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 拯救硬碟！「備份檔終結者」腳本上陣</h2>



<p class="wp-block-paragraph">為了避免「手動刪檔刪到手軟」或是「誤刪最新備份直接準備提頭來見」，我們需要一個具備以下特性的自動化腳本：</p>



<ol class="wp-block-list">
<li><strong>預設安全機制（Dry-Run）</strong>：先預覽「會刪哪些檔」，確認無誤再真的動手，安全感滿分！</li>



<li><strong>彈性門檻設定</strong>：預設保留 180 天，但也支援隨時調整成 30 天或 90 天。</li>



<li><strong>明確的視覺回饋</strong>：清楚標示每個檔案過期了多久（例如：<code>185.2 天前</code>），不再瞎猜。</li>
</ol>



<h3 class="wp-block-heading"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f4bb.png" alt="💻" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 靈魂核心程式碼</h3>



<p class="wp-block-paragraph">來看看這個輕巧又強大的 Python 腳本（<code>clean_bak.py</code>）：</p>



<pre class="wp-block-code"><code>import argparse
import sys
import time
from pathlib import Path


def cleanup_bak_files(
    target_dir: str, days: int = 180, execute_delete: bool = False
) -&gt; None:
    target_path = Path(target_dir).resolve()

    if not target_path.exists() or not target_path.is_dir():
        print(f"&#x274c; 錯誤：目標目錄 '{target_path}' 不存在。")
        sys.exit(1)

    now = time.time()
    files_found = 0
    files_matched = 0

    mode_label = (
        "&#x1f525;【實際刪除模式】" if execute_delete else "&#x1f6e1;【模擬預覽模式 (Dry-Run)】"
    )
    print(f"&#x1f3af; 目標目錄: {target_path}")
    print(f"&#x23f3; 保留天數門檻: {days} 天")
    print(f"&#x2699; 執行模式: {mode_label}\n" + "-" * 60)

    # 搜尋目錄下的所有 .bak 檔案
    for file_path in target_path.glob("*.bak"):
        files_found += 1
        mtime = file_path.stat().st_mtime
        file_age_days = (now - mtime) / 86400

        if file_age_days &gt; days:
            files_matched += 1
            age_formatted = f"{file_age_days:.1f} 天前"

            if execute_delete:
                try:
                    file_path.unlink()
                    print(
                        f"&#x1f5d1; &#91;已刪除]     {file_path.name} (修改時間: {age_formatted})"
                    )
                except Exception as e:
                    print(f"&#x26a0; &#91;刪除失敗]   無法刪除 {file_path.name}: {e}")
            else:
                print(
                    f"&#x1f440; &#91;將會刪除]   {file_path.name} (修改時間: {age_formatted})"
                )

    print("-" * 60)
    if not execute_delete:
        print(
            f"&#x1f4a1; 預覽結束：在 {files_found} 個 `.bak` 檔案中，發現 {files_matched} 個超過 {days} 天老舊檔案。"
        )
        if files_matched &gt; 0:
            print("&#x1f449; 若要真正刪除這些檔案，請加上 `--delete` 參數重新執行！")
    else:
        print(
            f"&#x1f389; 清理完成：已成功釋放空間！刪除了 {files_matched} 個過期檔案。"
        )


if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description="預覽或刪除超過指定天數的 .bak 備份檔案。"
    )
    parser.add_argument(
        "directory",
        nargs="?",
        default=".",
        help="備份檔案所在的目錄路徑（預設為目前目錄）",
    )
    parser.add_argument(
        "--days",
        type=int,
        default=180,
        help="檔案保留天數上限（預設：180 天）",
    )
    parser.add_argument(
        "--delete",
        action="store_true",
        help="加入此參數以進行「實際刪除」操作",
    )

    args = parser.parse_args()
    cleanup_bak_files(args.directory, days=args.days, execute_delete=args.delete)
</code></pre>



<h2 class="wp-block-heading"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f6e0.png" alt="🛠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> 實戰演練：三步驟教你輕鬆上手</h2>



<h3 class="wp-block-heading">1&#x20e3; 第一步：演習比實戰重要！先來個「預覽演練」</h3>



<p class="wp-block-paragraph">預設狀態下，腳本只會「看」不會「刪」。直接執行腳本，看看有哪些老古董檔案準備該下架：</p>



<pre class="wp-block-code"><code>python clean_bak.py /var/backups/portal
</code></pre>



<p class="wp-block-paragraph"><strong>輸出結果超清晰：</strong></p>



<pre class="wp-block-code"><code>&#x1f3af; 目標目錄: /var/backups/portal
&#x23f3; 保留天數門檻: 180 天
&#x2699; 執行模式: &#x1f6e1;【模擬預覽模式 (Dry-Run)】
------------------------------------------------------------
&#x1f440; &#91;將會刪除]   portal_backup_2026_01_01_000000.bak (修改時間: 220.4 天前)
------------------------------------------------------------
&#x1f4a1; 預覽結束：在 25 個 `.bak` 檔案中，發現 1 個超過 180 天老舊檔案。
&#x1f449; 若要真正刪除這些檔案，請加上 `--delete` 參數重新執行！
</code></pre>



<h3 class="wp-block-heading">2&#x20e3; 第二步：確定無誤，開火刪除！</h3>



<p class="wp-block-paragraph">確認過清單後，加上 <code>--delete</code> 參數，給過期檔案來個大掃除：</p>



<pre class="wp-block-code"><code>python clean_bak.py /var/backups/portal --delete
</code></pre>



<p class="wp-block-paragraph">瞬間，硬碟空間就回歸綠色健康狀態，空氣都變得新鮮了！</p>



<h3 class="wp-block-heading">3&#x20e3; 第三步：交給排程工具，一勞永逸！</h3>



<p class="wp-block-paragraph">工程師的終極目標就是「能自動化就絕不手動」。設定好 <strong>Linux Crontab</strong> 或 <strong>Windows 工作排程器</strong>，讓伺服器每天半夜自動執行清理：</p>



<ul class="wp-block-list">
<li><strong>Linux (Crontab)：</strong><br><code>0 2 * * * /usr/bin/python3 /path/to/clean_bak.py /var/backups/portal --delete >> /var/log/clean_bak.log 2>&amp;1<br></code><em>每天凌晨 02:00 自動掃描並清理，還會把紀錄寫進 log 檔，安心度 100%！</em></li>
</ul>



<h2 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;" /> 經驗談：備份維護的黃金法則</h2>



<ol class="wp-block-list">
<li><strong>離線/異地備份才是真愛</strong>：本地端只保留近 30~180 天的快取備份，更久遠的歷史資料請定期同步至 AWS S3、Google Cloud Storage 或冷儲存（Glacier）。</li>



<li><strong>權限控管要做好</strong>：確保執行清理腳本的系統帳號只有該目錄的權限，避免權限過大衍生安全風險。</li>



<li><strong>定期測試還原</strong>：<strong>能還原的備份才叫備份，不能還原的只是佔空間的垃圾！</strong> 記得定期做備份還原演練喔！</li>
</ol>



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



<p class="wp-block-paragraph">別再讓爆掉的硬碟毀了你的週末！快把這套 Python 腳本部署上線，讓伺服器自動保持清爽健康吧！</p>



<p class="wp-block-paragraph">你平時是用什麼方式管理資料庫備份的呢？歡迎在下方留言交流你的「硬碟省空間妙招」！ <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f447.png" alt="👇" class="wp-smiley" style="height: 1em; max-height: 1em;" /></p>



<p class="wp-block-paragraph">直接編輯或用 Gemini 編輯</p>



<p class="wp-block-paragraph">點選任一處即可直接輸入及編輯，或選取文字，提示 Gemini 進行修改。</p>



<p class="wp-block-paragraph"></p>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/09/clean_bak/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>將 Pi Coding Agent 無縫對接本地 llama-server 自訂模型</title>
		<link>https://stackoverflow.max-everyday.com/2026/08/pi-coding-agent-llama-server/</link>
					<comments>https://stackoverflow.max-everyday.com/2026/08/pi-coding-agent-llama-server/#respond</comments>
		
		<dc:creator><![CDATA[max-stackoverflow]]></dc:creator>
		<pubDate>Sun, 30 Aug 2026 05:16:06 +0000</pubDate>
				<category><![CDATA[電腦相關應用]]></category>
		<guid isPermaLink="false">https://stackoverflow.max-everyday.com/?p=8683</guid>

					<description><![CDATA[在體驗 LLM 驅動的命令列開發工具時，許多人喜...]]></description>
										<content:encoded><![CDATA[
<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="1024" height="572" src="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/08/watermarked_img_11904330739739367754.jpg?v=1788066680" alt="" class="wp-image-8685" srcset="https://stackoverflow.max-everyday.com/wp-content/uploads/2026/08/watermarked_img_11904330739739367754.jpg?v=1788066680 1024w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/08/watermarked_img_11904330739739367754-600x335.jpg?v=1788066680 600w, https://stackoverflow.max-everyday.com/wp-content/uploads/2026/08/watermarked_img_11904330739739367754-768x429.jpg?v=1788066680 768w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">在體驗 LLM 驅動的命令列開發工具時，許多人喜歡在本地使用 <code>llama-server.exe</code> 載入 GGUF 模型（如 Qwen 系列），既能節省 API 費用又能保護隱私。</p>



<p class="wp-block-paragraph">不過，當嘗試將 <strong>Pi Coding Agent</strong> 連接到本地伺服器時，常會遇到 <code>Server is not running in llama.cpp router mode</code> 或 OpenAI API <code>401 Incorrect API key</code> 等驗證錯誤。</p>



<p class="wp-block-paragraph">這篇文章記錄了完整的除錯過程與最終解決方案，幫助你快速完成本地環境設定。</p>



<h2 class="wp-block-heading">問題解析</h2>



<p class="wp-block-paragraph">為什麼預設情況下會連線失敗？</p>



<ul class="wp-block-list">
<li><strong>端點路由不符</strong>：Pi Agent 預設對接 <code>llama.cpp</code> 時，期待對方開啟多模型路由（Router Mode）。如果本地只是單純啟動單一 GGUF 模型，會無法通過驗證。</li>



<li><strong>API Key 驗證攔截</strong>：如果 <code>llama-server</code> 開啟了 <code>--api-key</code>，Pi Agent 若未帶入對應金鑰，或是被預設路徑引導至 OpenAI 官方伺服器，就會觸發 401 Unauthorized 錯誤。</li>
</ul>



<h2 class="wp-block-heading">解決步驟</h2>



<h3 class="wp-block-heading">步驟一：修改 llama-server 啟動批次檔</h3>



<p class="wp-block-paragraph">首先，確保你的 <code>llama-server.exe</code> 啟動時明確設定了 API Key。</p>



<p class="wp-block-paragraph">在你的批次檔（例如 <code>start-server.bat</code>）中加入 <code>--api-key</code> 參數：</p>



<pre class="wp-block-code"><code>set MODEL=models\Qwen3.8-27B-UD-IQ1_S.gguf
set EXE=llama-server.exe

REM Large context for long code.
set CTX=16384
set BATCH=512
set NP=1

"%EXE%" ^
  -m %MODEL% ^
  -c %CTX% ^
  -np %NP% ^
  -cmoe ^
  -b %BATCH% -ub %BATCH% ^
  -ngl 999 ^
  --port 8080 ^
  --host 127.0.0.1 ^
  --api-key 12345678 ^
  -fa on ^
  -rea off ^
  --reasoning-format none ^
  --temp 0.3 ^
  --top-p 0.8 ^
  --top-k 30 ^
  --repeat-penalty 1.08 ^
  --context-shift</code></pre>



<p class="wp-block-paragraph">上面參數微調, 參考看看: 「思維鏈坍塌」超低位元量化模型遇到無休止內部思考、自我糾正<br><a href="https://stackoverflow.max-everyday.com/2026/08/chain-of-thought-collapse/">https://stackoverflow.max-everyday.com/2026/08/chain-of-thought-collapse/</a></p>



<p class="wp-block-paragraph">啟動後，可以使用 CMD 的 <code>curl</code> 測試 OpenAI 相容端點是否正常運作：</p>



<pre class="wp-block-code"><code>curl http://127.0.0.1:8080/v1/models -H "Authorization: Bearer 12345678"
</code></pre>



<p class="wp-block-paragraph">若有正確返回 JSON 格式的模型清單，代表伺服器端設定完成。</p>



<h3 class="wp-block-heading">步驟二：配置 Pi Agent 的自訂 Provider</h3>



<p class="wp-block-paragraph">Pi Agent 允許透過設定檔擴充自訂的 Provider。</p>



<p class="wp-block-paragraph">在 CMD 中建立並開啟設定檔：</p>



<pre class="wp-block-code"><code>if not exist "%USERPROFILE%\.pi\agent" mkdir "%USERPROFILE%\.pi\agent"
notepad "%USERPROFILE%\.pi\agent\models.json"
</code></pre>



<p class="wp-block-paragraph">貼上以下 JSON 設定。重點在於要明確指定 <code>"api": "openai-completions"</code>，否則 Pi 會因為缺少通訊協定設定而報錯：</p>



<pre class="wp-block-code"><code>{
  "providers": {
    "local-llama": {
      "baseUrl": "http://127.0.0.1:8080/v1",
      "apiKey": "12345678",
      "api": "openai-completions",
      "models": &#91;
        {
          "id": "models\\Qwen3.8-27B-UD-IQ1_S.gguf",
          "name": "Qwen3.8-Local",
          "contextWindow": 16384,
          "maxTokens": 4096
        },
        {
          "id": "models\\gemma-4-12B-it-qat-UD-Q4_K_XL.gguf",
          "name": "Gemma-4-12B-Local",
          "contextWindow": 16384,
          "maxTokens": 4096
        }
      ]
    }
  }
}</code></pre>



<p class="wp-block-paragraph">實際測試，模型名稱寫錯，還是可以正常執行，滿神奇的。</p>



<h3 class="wp-block-heading">步驟三：驗證並啟動 Pi Agent</h3>



<p class="wp-block-paragraph">設定完成後，在 CMD 執行模型清單檢視指令：</p>



<pre class="wp-block-code"><code>pi --list-models
</code></pre>



<p class="wp-block-paragraph">確認列表中出現了 <code>local-llama</code> 相關模型。</p>



<p class="wp-block-paragraph">接著建立一個專用的啟動批次檔 <code>run-pi-qwen3.8.bat</code>：</p>



<pre class="wp-block-code"><code>@echo off
pi --model "local-llama/models\Qwen3.8-27B-UD-IQ1_S.gguf"
</code></pre>



<p class="wp-block-paragraph">執行 <code>run-pi.bat</code> 即可順利在 Pi Agent 中與本地模型進行對話！</p>



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



<p class="wp-block-paragraph">解決此問題的核心在於：</p>



<ol start="1" class="wp-block-list">
<li>本地伺服器需顯式指定 API Key 並提供 OpenAI 相容端點。</li>



<li>Pi Agent 的 <code>models.json</code> 必須完整填寫 <code>api: "openai-completions"</code> 規範。</li>
</ol>



<p class="wp-block-paragraph">透過自訂 Provider 機制，不僅能繞過官方 API 限制，還能靈活切換各種在地端運行的 GGUF 大語言模型！</p>



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



<p class="wp-block-paragraph">選 llama.cpp 比較好的感覺, 可以設定的參數比較多.</p>



<p class="wp-block-paragraph"><strong>ollama</strong> 與 <strong>llama.cpp</strong> 底層皆基於 C/C++ 的推論引擎，但兩者的定位、使用對象與專案目標有著本質上的不同：</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><td><strong>特性</strong></td><td><strong>llama.cpp</strong></td><td><strong>Ollama</strong></td></tr></thead><tbody><tr><td><strong>主要定位</strong></td><td>底層推論核心 / 開發者工具</td><td>上層封裝與管理工具 / 終端使用者應用</td></tr><tr><td><strong>使用門檻</strong></td><td>較高（需熟悉指令列參數、自行下載模型與設定）</td><td>極低（一鍵安裝，具備類似 Docker 的指令與體驗）</td></tr><tr><td><strong>模型格式</strong></td><td><strong>GGUF</strong></td><td><strong>Modelfile</strong>（內部打包並調用 GGUF）</td></tr><tr><td><strong>服務架構</strong></td><td>原生編譯後為單一可執行檔或 C++ 函式庫</td><td>後台背景服務 (Daemon) + CLI 前端</td></tr><tr><td><strong>生態系統</strong></td><td>提供各種綁定 (Python, Rust 等) 與低階 API</td><td>提供相容 OpenAI 的 REST API，整合開源 UI 極佳</td></tr></tbody></table></figure>



<h3 class="wp-block-heading">關鍵差異解析</h3>



<p class="wp-block-paragraph"><strong>1. llama.cpp：極致效能與底層控制</strong></p>



<ul class="wp-block-list">
<li><strong>核心價值</strong>：由 Georgi Gerganov 開發，旨在讓大型語言模型能在無 GPU 或硬體受限的普通消費級設備（如 Mac Apple Silicon、普通 PC）上高效運行。</li>



<li><strong>優勢</strong>：
<ul class="wp-block-list">
<li><strong>細粒度控制</strong>：可直接調整 KV 快取、GPU 層數分流 (<code>-ngl</code>)、Context 長度、Sampler 參數等。</li>



<li><strong>高擴充性</strong>：身為基礎架構，被無數上層工具（如 Python 庫 <code>llama-cpp-python</code>、text-generation-webui 等）整合。</li>
</ul>
</li>



<li><strong>劣勢</strong>：設定繁瑣，下載的模型需要手動管理檔案路徑與參數設定。</li>
</ul>



<p class="wp-block-paragraph"><strong>2. Ollama：極簡體驗與模型生態</strong></p>



<ul class="wp-block-list">
<li><strong>核心價值</strong>：將 llama.cpp 包裝成極簡化的桌面/伺服器工具，核心體驗借鑑了 Docker。</li>



<li><strong>優勢</strong>：
<ul class="wp-block-list">
<li><strong>開箱即用</strong>：只需執行 <code>ollama run llama3</code>，就會自動下載模型並直接啟動對話。</li>



<li><strong>模型庫管理</strong>：擁有官方模型庫 (library)，下載與更新非常方便。</li>



<li><strong>標準化 API</strong>：預設提供開箱即用的 REST API，能 seamlessly 介接 Open WebUI、AnythingLLM 或各類本地插件。</li>
</ul>
</li>



<li><strong>劣勢</strong>：預設封裝隱藏了許多底層參數，若要高度客製化推論細節，需要透過編輯 <code>Modelfile</code> 完成。</li>
</ul>



<h3 class="wp-block-heading">該如何選擇？</h3>



<ul class="wp-block-list">
<li>選 <strong>Ollama</strong>：如果你想要快速在本地端跑起 LLM、連結現成的 Web UI，或是為自己的應用程式快速接上本地 API。</li>



<li>選 <strong>llama.cpp</strong>：如果你是 C/C++ 開發者、需要整合嵌入式系統、或者需要對硬體推論細節進行極致優化與自訂。</li>
</ul>
]]></content:encoded>
					
					<wfw:commentRss>https://stackoverflow.max-everyday.com/2026/08/pi-coding-agent-llama-server/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
