EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Web / Build APM บน Windows ตอนที่ 7: Deploy รัน และอัปเดต security patch
Web

Build APM บน Windows ตอนที่ 7: Deploy รัน และอัปเดต security patch

โดย Ton-Or ·22 ต.ค. 2026·อ่าน 20 นาที
Build APM บน Windows ตอนที่ 7: Deploy รัน และอัปเดต security patch

ของที่ build เสร็จยังอยู่ใน stage\ ซึ่งเป็นโฟลเดอร์ชั่วคราว ตอนนี้เอามันลง C:\APM สตาร์ตให้เห็นกับตา และเก็บขั้นตอนอัปเดตครั้งต่อไปไว้ให้ทำซ้ำได้โดยไม่ต้องจำ

พอดีผมมาถึงตอนสุดท้ายของซีรีส์ และเป็นตอนที่ผมตั้งใจเขียนให้ใช้ซ้ำได้มากที่สุด เพราะเหตุผลทั้งหมดที่ผมลงมือ build Apache, PHP และ MariaDB เองคือเมื่อ PHP ปล่อย 8.4.27 แก้ RCE สองตัวเมื่อ 21 ตุลาคม 2026 (เรื่องเต็มอยู่ในข่าว php2shell) ผมอยากแก้เลขเวอร์ชันไฟล์เดียวแล้ว build ใหม่ได้ภายในวัน ไม่ต้องรอ bundle อย่าง XAMPP ที่ในเครื่องผมยังค้างที่ PHP 8.2.12 จากปี 2023

ตอนนี้มีสามงาน งานแรกคือเอาของใน stage\ ลง C:\APM ด้วย deploy.cmd งานที่สองคือสตาร์ต MariaDB กับ Apache ครั้งแรก ทดสอบ แล้วปิด ซึ่งผมเจอบั๊กจริงหนึ่งตัวตรงนี้และจะเล่าให้ครบ งานที่สามคือขั้นตอนอัปเดตเมื่อมี security release ครั้งหน้า ทุกขั้นตอนมีเลขกำกับ บอกว่ารันที่ terminal ไหน ควรเห็นอะไร และตรวจอย่างไร นอกจากสามงานนี้ ตอนนี้มีหัวข้อเสริมอีกสองเรื่อง คือการเปิดให้เครื่องใน LAN เข้า และบั๊กอีกห้าตัวที่ผมเจอหลังซีรีส์ออก

กติกาของทั้งตอนนี้

ใช้ PowerShell 7 (pwsh) แบบ user ปกติ เสมอ ไม่ต้องเปิดแบบ admin คำสั่งที่รันจากโฟลเดอร์ build ให้อยู่ที่ C:\apm-build ส่วนคำสั่งที่เรียกโปรแกรมใน C:\APM ใช้พาธเต็มทุกครั้ง รันจากโฟลเดอร์ไหนก็ได้ ตัวพิมพ์ใหญ่เล็กของ APM กับ apm-build ต่างกัน ระวังอย่าสลับ

ตอนนี้ปรับตาม kit รุ่นบ่ายวันที่ 22 ตุลาคม

หลังซีรีส์ออก ผมแก้สคริปต์ deploy กับ template ไปหลายจุด คำสั่ง สคริปต์ และตารางทั้งหมดในตอนนี้ตรงกับ kit รุ่นใหม่แล้ว เพิ่มของใหม่คือ -Lan, trust-ca.ps1, new-cert.ps1, firewall-lan.ps1 และ set-path.ps1 ใครโหลด kit ก่อนหน้านั้น อ่านวิธีอัปเดตในหัวข้อ "บั๊กที่เจอหลังซีรีส์ออก" ด้านล่าง

ก่อน deploy ตรวจของและเลือกพอร์ต

deploy.cmd ไม่สตาร์ตอะไรเลย มันแค่คัดลอกและสร้าง config เลยปลอดภัยที่จะรันซ้ำ แต่มีเงื่อนไขเริ่มต้นสี่ข้อที่ต้องเช็กก่อน

ขั้นที่ 1: เปิด terminal แล้วไปที่โฟลเดอร์ build

cd C:\apm-build

ขั้นที่ 2: ตรวจว่า stage มีของครบสามส่วน

Test-Path C:\apm-build\stage\apache\bin\httpd.exe
Test-Path C:\apm-build\stage\php\php.exe
Test-Path C:\apm-build\stage\mariadb\bin\mariadbd.exe

ผลที่ควรเห็นคือ True สามบรรทัด

True
True
True

ถ้าบรรทัดไหนเป็น False ให้กลับไปทำตอนที่ 3 (Apache), 4 (MariaDB) หรือ 6 (PHP) ให้ครบก่อน ถ้าข้ามไป deploy.cmd จะหยุดเองด้วยข้อความ [ERR] ...\stage\apache missing - build first (ชื่อโฟลเดอร์จะเปลี่ยนตามส่วนที่ขาด)

ขั้นที่ 3: ตรวจว่าไม่มี APM ตัวเก่ารันอยู่

สคริปต์ปฏิเสธการ deploy ถ้ามี httpd, mariadbd หรือ php-cgi ที่มาจาก C:\APM ทำงานอยู่ เพราะ Windows ไม่ยอมให้เขียนทับไฟล์ที่กำลังถูกใช้ และรุ่นใหม่ปฏิเสธด้วยถ้ามี Windows service APM-Apache หรือ APM-MariaDB รันอยู่ ตรวจทั้งสองอย่างด้วยคำสั่งนี้

Get-Process httpd,mariadbd,php-cgi -ErrorAction SilentlyContinue | Where-Object { $_.Path -like 'C:\APM\*' }
Get-Service APM-Apache,APM-MariaDB -ErrorAction SilentlyContinue

ผลที่ควรเห็นคือ ไม่มีอะไรพิมพ์ออกมาเลย สองคำสั่งนี้ ถ้ามีตารางโผล่จากคำสั่งแรก แปลว่ามี process ค้างอยู่ ให้ไปทำขั้นหยุด APM ในหัวข้อสตาร์ตครั้งแรกก่อน แล้วค่อยกลับมา ส่วนคำสั่งที่สองจะมีแถวโผล่ก็ต่อเมื่อเคยติดตั้ง APM แบบพร้อมใช้ที่ลงเป็น service ไว้ ถ้ามีแถวและสถานะไม่ใช่ Stopped ให้หยุดมันก่อน (Stop-Service ต้องใช้ pwsh แบบ admin) ที่ต้องมีข้อตรวจแยก เพราะ service รันเป็นบัญชี LocalService ซึ่ง Get-Process มองไม่เห็น .Path ของมัน ถ้าลืม สคริปต์ deploy จะหยุดด้วย [ERR] service APM-Apache is running - stop APM before deploying

ขั้นที่ 4: ตรวจว่าพอร์ตที่จะใช้ว่าง

ค่าเริ่มต้นของสคริปต์ตั้งใจเลี่ยงพอร์ตที่ XAMPP ใช้ จะได้ติดตั้งคู่กันได้

ตัวเลือก ค่าเริ่มต้น XAMPP ใช้ ใช้ทำอะไร
-HttpPort 8080 80 HTTP ของ Apache
-HttpsPort 8443 443 HTTPS ของ Apache
-DbPort 3307 3306 MariaDB
-Target C:\APM - โฟลเดอร์ติดตั้ง
-Lan ปิด - Apache ฟังทุก interface (0.0.0.0) แทน 127.0.0.1 และใบรับรองครอบชื่อเครื่องกับ IP ของ LAN (MariaDB ยังอยู่ที่ 127.0.0.1 เสมอ) ดูหัวข้อ LAN
-ResetConfig ปิด - สร้าง config ใหม่จาก templates\ ทับของเดิม ออกใบรับรองเซิร์ฟเวอร์ใหม่ และ sync data\mysql\my.ini

ตรวจว่าสามพอร์ตนี้ยังไม่มีใครใช้

Get-NetTCPConnection -LocalPort 8080,8443,3307 -ErrorAction SilentlyContinue

ผลที่ควรเห็นคือไม่มีอะไรพิมพ์ออกมา ถ้ามีแถวโผล่แปลว่ามีโปรแกรมอื่นใช้พอร์ตอยู่ ให้เลือกพอร์ตอื่นโดยใส่ตัวเลือกตอนรัน deploy.cmd เช่น -HttpPort 8081 ในซีรีส์นี้ผมใช้ค่าเริ่มต้นทั้งหมด ขนาดที่ใช้ลงดิสก์ประมาณ 650 MB (apache 20, php 124, mariadb 201, data 302 โดยประมาณ)

90-deploy.ps1 ทำอะไรบ้างทีละช่วง

ตัวห่อ deploy.cmd สั้นมาก ตรวจว่ามี pwsh แล้วเรียกสคริปต์พร้อมส่งอาร์กิวเมนต์ต่อ (ไฟล์นี้ไม่เปลี่ยนจากรุ่นแรก ตัวเลือกใหม่อย่าง -Lan ผ่านไปที่สคริปต์ผ่าน %* เอง)

@echo off
rem deploy.cmd - install stage\ into C:\APM (does not start anything)
rem   deploy.cmd                                       defaults: C:\APM, http 8080, https 8443, db 3307
rem   deploy.cmd -Target D:\APM -HttpPort 80 -HttpsPort 443 -DbPort 3306
rem   deploy.cmd -ResetConfig                          re-render config files from templates\
setlocal
where pwsh >nul 2>&1 || (echo [ERR] PowerShell 7 ^(pwsh^) not found - see https://linuxthai.org/article/apm-build-windows-1-prepare-tools & exit /b 1)
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\90-deploy.ps1" %*
exit /b %ERRORLEVEL%

ตัวสคริปต์จริงคือไฟล์นี้ เป็นรุ่นใหม่ของ kit ผมวางทั้งไฟล์ไม่ตัดบรรทัด

# 90-deploy.ps1 - install the built stage\ into a runtime folder (default C:\APM). Does NOT start anything.
#   pwsh -File scripts\90-deploy.ps1 [-Target C:\APM] [-HttpPort 8080] [-HttpsPort 8443] [-DbPort 3307] [-Lan]
# Re-running upgrades binaries in place; existing config files, data, www, ssl and logs are kept
# (use -ResetConfig to re-render config from templates\).
# -Lan: Apache listens on all interfaces + the cert covers this PC's name and LAN IPs. MariaDB always
#       stays on 127.0.0.1 (root has no password). Windows Firewall still needs scripts\firewall-lan.ps1 (admin).
param(
    [string]$Target = 'C:\APM',
    [int]$HttpPort = 8080, [int]$HttpsPort = 8443, [int]$DbPort = 3307,   # XAMPP keeps 80/443/3306 meanwhile
    [switch]$Lan,
    [switch]$ResetConfig
)
$Bind = if ($Lan) { '0.0.0.0' } else { '127.0.0.1' }
$LanIPs = @()
if ($Lan) {
    $LanIPs = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue |
        Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' -and $_.AddressState -eq 'Preferred' } |
        ForEach-Object IPAddress
}
$ErrorActionPreference = 'Stop'
$Root  = Split-Path $PSScriptRoot -Parent
$Stage = "$Root\stage"
foreach ($c in 'apache','php','mariadb') { if (-not (Test-Path "$Stage\$c")) { throw "[ERR] $Stage\$c missing - build first" } }
foreach ($exe in 'httpd','mariadbd','php-cgi') {
    $p = Get-Process $exe -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "$Target\*" }
    if ($p) { throw "[ERR] $exe from $Target is running - stop APM before deploying" }
}
# services run as LocalService: their processes are not visible to Get-Process .Path -> ask the SCM
$svc = Get-Service 'APM-Apache','APM-MariaDB' -ErrorAction SilentlyContinue | Where-Object Status -ne 'Stopped'
if ($svc) { throw "[ERR] service $($svc.Name -join ', ') is running - stop APM before deploying" }

# 1. binaries (robocopy /MIR per component, minus debug symbols and dev files)
# /XD and /XF match names on both sides, so they also keep /MIR from deleting the target's conf/logs/ini
$skip = @{
    apache  = @('/XD', 'include', 'lib', 'conf', 'logs', '/XF', '*.pdb', '*.lib', '*.exp')
    php     = @('/XF', '*.pdb', '*.lib', '*.exp', 'php.ini')
    # NOT 'lib' here: mariadb\lib\plugin holds the plugin DLLs (ha_archive, auth_ed25519, ...); *.lib files are dropped by /XF
    mariadb = @('/XD', 'include', 'symbols', 'sql-bench', '/XF', '*.pdb', '*.lib', '*.exp', 'my.ini')
}
foreach ($c in 'apache','php','mariadb') {
    robocopy "$Stage\$c" "$Target\$c" /MIR /R:1 /W:1 /NFL /NDL /NJH /NJS /NP @($skip[$c]) | Out-Null
    if ($LASTEXITCODE -ge 8) { throw "[ERR] copy $c failed (robocopy rc=$LASTEXITCODE)" }
    Write-Host "[OK] $c -> $Target\$c"
}
# templates + tools travel with the install (re-render config later, new-cert.ps1)
foreach ($d in 'templates','tools') {
    robocopy "$Root\$d" "$Target\$d" /MIR /R:1 /W:1 /NFL /NDL /NJH /NJS /NP | Out-Null
    if ($LASTEXITCODE -ge 8) { throw "[ERR] copy $d failed" }
}
# license of the build scripts (MIT + Commons Clause)
Copy-Item "$Root\LICENSE" "$Target\LICENSE.txt" -Force
# Apache conf data files (mime.types, magic ...) - copy, but never overwrite an edited httpd.conf
New-Item -ItemType Directory -Force "$Target\apache\conf\extra", "$Target\apache\conf\vhosts" | Out-Null
Get-ChildItem "$Stage\apache\conf" -File | Where-Object Name -ne 'httpd.conf' | Copy-Item -Destination "$Target\apache\conf" -Force

# 2. runtime folders
foreach ($d in 'www\default','logs\apache','logs\php','logs\mariadb','tmp\sessions','tmp\uploads','tmp\opcache','ssl','data') {
    New-Item -ItemType Directory -Force "$Target\$d" | Out-Null
}

# 3. config from templates (only if missing, or -ResetConfig)
# CACHE_ID: per-folder OPcache segment name, derived from the install path
$sha1 = [Security.Cryptography.SHA1]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes(($Target -replace '\\','/').ToLower()))
$cacheId = 'apm' + (-join ($sha1[0..3] | ForEach-Object { $_.ToString('x2') }))
$vars = @{ APM_ROOT = ($Target -replace '\\','/'); HTTP_PORT = $HttpPort; HTTPS_PORT = $HttpsPort; DB_PORT = $DbPort; BIND = $Bind; CACHE_ID = $cacheId }
function Render($tpl, $dest, [switch]$WinPaths) {
    if ((Test-Path $dest) -and -not $ResetConfig) { Write-Host "[KEEP] $dest"; return }
    $t = Get-Content "$Root\templates\$tpl" -Raw
    foreach ($k in $vars.Keys) {
        $v = if ($WinPaths -and $k -eq 'APM_ROOT') { $Target } else { "$($vars[$k])" }
        $t = $t.Replace("{{$k}}", $v)
    }
    Set-Content $dest $t -Encoding ascii -NoNewline
    Write-Host "[OK] $dest"
}
Render 'httpd.conf'    "$Target\apache\conf\httpd.conf"
Render 'apm-ssl.conf'  "$Target\apache\conf\extra\apm-ssl.conf"
Render 'php.ini'       "$Target\php\php.ini" -WinPaths
Render 'my.ini'        "$Target\mariadb\my.ini"
Copy-Item "$Root\versions.json" "$Target\versions.json" -Force

# 4. CA bundle for curl/openssl in PHP (Mozilla bundle from curl.se, sha256-checked)
$ca = "$Target\ssl\cacert.pem"
if (-not (Test-Path $ca) -or $ResetConfig) {
    curl.exe -fsSL -o "$ca.part" https://curl.se/ca/cacert.pem
    $want = ((curl.exe -fsSL https://curl.se/ca/cacert.pem.sha256) -split '\s+')[0]
    if ((Get-FileHash "$ca.part" -Algorithm SHA256).Hash -ne $want) { Remove-Item "$ca.part"; throw '[ERR] cacert.pem sha256 mismatch' }
    Move-Item -Force "$ca.part" $ca
    Write-Host "[OK] $ca"
}

# 5. local CA + localhost certificate (generated with APM's own php.exe). The CA is created once and
#    kept; the server cert is re-issued on -ResetConfig/-Lan so its SAN matches how the site is reached.
if (-not (Test-Path "$Target\ssl\localhost.crt") -or $ResetConfig -or $Lan) {
    $certHosts = @('localhost', '127.0.0.1', '::1')
    if ($Lan) { $certHosts += @($env:COMPUTERNAME.ToLower()) + $LanIPs }
    & "$Target\php\php.exe" -n -d "extension_dir=`"$Target\php\ext`"" -d extension=openssl `
        "$Root\tools\mkcert.php" "$Target\ssl" @certHosts
    if ($LASTEXITCODE) { throw '[ERR] certificate generation failed' }
}

# 6. default page
if (-not (Test-Path "$Target\www\default\index.php")) {
    Copy-Item "$Root\templates\index.php" "$Target\www\default\index.php"
}

# 7. MariaDB datadir (bootstrap only - no network listener is opened)
if (-not (Test-Path "$Target\data\mysql\mysql")) {
    & "$Target\mariadb\bin\mariadb-install-db.exe" --datadir="$Target\data\mysql" --port=$DbPort --config="$Target\mariadb\my.ini" --silent
    if ($LASTEXITCODE) { throw '[ERR] mariadb-install-db failed' }
    Write-Host "[OK] datadir $Target\data\mysql (root@localhost, no password)"
} elseif ($ResetConfig) {
    # the server runs with <datadir>\my.ini (written once by mariadb-install-db) -> keep it in sync
    Copy-Item "$Target\mariadb\my.ini" "$Target\data\mysql\my.ini" -Force
    Write-Host "[OK] $Target\data\mysql\my.ini (synced)"
}

# 8. config syntax check (parses only, binds nothing)
& "$Target\apache\bin\httpd.exe" -t
if ($LASTEXITCODE) { throw '[ERR] httpd -t failed' }
$live = Select-String -Path "$Target\apache\conf\httpd.conf" -Pattern '^Listen\s+(\S+)' | ForEach-Object { $_.Matches[0].Groups[1].Value }
Write-Host "[OK] APM deployed to $Target  (http :$HttpPort, https :$HttpsPort, db 127.0.0.1:$DbPort) - not started"
Write-Host "     httpd.conf Listen: $live"
if ($Lan) {
    if ($live -like '127.0.0.1:*') { Write-Host '[WARN] httpd.conf was kept and still binds 127.0.0.1 - add -ResetConfig to switch it to LAN' }
    foreach ($ip in $LanIPs) { Write-Host "     LAN: http://${ip}:$HttpPort/  https://${ip}:$HttpsPort/" }
    Write-Host '     Firewall: run scripts\firewall-lan.ps1 once as Administrator'
}

(บล็อกแรกเป็นไฟล์ batch แต่ผมใช้ fence powershell เพราะเว็บนี้ไม่มีตัวไฮไลต์สำหรับ batch)

ผมแบ่งสคริปต์เป็นแปดช่วงตามคอมเมนต์ในไฟล์ บวกส่วนหัวที่เพิ่มมาอีกหนึ่งส่วน ไล่ตามลำดับที่มันรัน

ลำดับใน deployตรวจstage, process, service->คัดลอกrobocopy + templates tools LICENSE->configBIND, CACHE_ID->cert + datadirsync my.ini->httpd -tพิมพ์ Listen

ส่วนหัว: ตัวเลือก -Lan และตัวแปรที่ตั้งก่อน

param มีตัวเลือกใหม่หนึ่งตัวคือ -Lan ใต้ param สคริปต์ตั้ง $Bind เป็น 0.0.0.0 เมื่อมี -Lan และ 127.0.0.1 เมื่อไม่มี ค่านี้ไปเป็น {{BIND}} ใน template ของ Apache ภายหลัง ถ้ามี -Lan มันยังเก็บรายการ IPv4 ของเครื่องใน $LanIPs ด้วย โดยเอาที่อยู่ที่ไม่ขึ้นต้น 127. ไม่ขึ้นต้น 169.254. (ที่อยู่ที่เครื่องตั้งให้ตัวเองเมื่อไม่ได้ DHCP) และสถานะเป็น Preferred ไว้ใช้สองที่ คือใส่ใน SAN ของใบรับรอง และพิมพ์ URL บอกตอนจบ

ช่วงตรวจเงื่อนไข (บรรทัดบนสุด)

มีสามอย่างที่สคริปต์ตรวจ ถ้าล้มจะ throw ทันทีก่อนแตะไฟล์ใดๆ

  • stage\apache, stage\php, stage\mariadb ต้องมี ไม่งั้นพิมพ์ [ERR] <พาธ>\stage\<ส่วน> missing - build first
  • ไม่มี httpd, mariadbd, php-cgi จาก C:\APM รันอยู่ ไม่งั้นพิมพ์ [ERR] <ชื่อ exe> from C:\APM is running - stop APM before deploying
  • ใหม่: ไม่มี Windows service APM-Apache หรือ APM-MariaDB ที่ไม่ได้อยู่ในสถานะ Stopped ไม่งั้นพิมพ์ [ERR] service APM-Apache is running - stop APM before deploying (ชื่อ service ที่รันอยู่จะถูกเรียงต่อกันด้วยจุลภาค) เหตุผลที่ต้องมีข้อนี้แยก คือ service รันเป็นบัญชี LocalService ซึ่ง Get-Process อ่าน .Path ของมันจากบัญชีเราไม่ได้ ข้อตรวจแรกเลยมองไม่เห็น สคริปต์เลยถามตรงกับ Service Control Manager แทน service ชื่อนี้คือ service ที่ APM แบบพร้อมใช้ (ที่ผมแจกแยกต่างหาก ดูกล่อง "ไม่อยาก build เอง" ท้ายตอน) สร้างไว้ ถ้าคุณ build และ deploy เองตามซีรีส์นี้ ข้อนี้ผ่านเสมอ

ช่วงที่ 1: คัดลอก binary ด้วย robocopy

robocopy /MIR ทำให้ปลายทางเหมือนต้นทางเป๊ะ รวมถึงลบไฟล์ที่ต้นทางไม่มีด้วย เลยอันตรายถ้าไม่ระวัง สคริปต์เลยใส่ตัวกันไว้สองชั้น

  • /XD (ข้ามโฟลเดอร์) และ /XF (ข้ามไฟล์) ทำงานทั้งฝั่งต้นทางและปลายทาง ดังนั้น conf, logs ของ Apache, php.ini ของ PHP และ my.ini ของ MariaDB ที่อยู่ปลายทางเลยไม่ถูก /MIR ลบหรือทับ
  • ไม่คัดลอก *.pdb, *.lib, *.exp และโฟลเดอร์ include (ไฟล์สำหรับนักพัฒนา) ทำให้ไบนารีที่ติดตั้งเล็กกว่า stage

จุดที่แก้จากรุ่นแรก: ในรายการของ mariadb เดิมมีคำว่า lib อยู่ใน /XD ด้วย ตอนนี้เอาออกแล้ว และมีคอมเมนต์กำกับในสคริปต์ว่าทำไม เรื่องนี้ผมเล่าเต็มๆ ในหัวข้อบั๊กด้านล่าง ย่อๆ คือ /XD lib ตัดโฟลเดอร์ชื่อ lib ทุกตัวในทุกระดับ รวม mariadb\lib\plugin ที่เก็บ DLL ของ plugin ไปด้วย ตอนนี้ปล่อยโฟลเดอร์ lib ผ่าน และไฟล์ *.lib ถูกตัดด้วย /XF อยู่แล้ว ส่วนของ apache ยังมี lib ใน /XD ตามเดิม เพราะ stage\apache\lib มีแต่ไฟล์ .lib ของ APR กับ httpd ที่ใช้ตอน build ไม่มีอะไรที่ต้องใช้ตอนรัน

ตัวเลือกที่เหลือ: /R:1 /W:1 ลองซ้ำหนึ่งครั้งรอหนึ่งวินาทีเมื่อไฟล์ติด, /NFL /NDL /NJH /NJS /NP ปิดการพิมพ์รายชื่อไฟล์ ส่วนรหัสออกของ robocopy ตั้งแต่ 0 ถึง 7 ถือว่าสำเร็จ (บอกว่าคัดลอกอะไรไปบ้าง) ตั้งแต่ 8 ขึ้นไปคือล้มจริง สคริปต์เลยเช็ก -ge 8

หลังลูปของสามส่วนนี้มีของที่เพิ่มมาใหม่สามอย่าง

  • templates\ และ tools\ ถูก mirror ไปที่ C:\APM\templates และ C:\APM\tools ด้วย robocopy /MIR เหมือนกัน (ล้มเมื่อรหัสออก 8 ขึ้นไปด้วย [ERR] copy templates failed) เหตุผลคือให้ C:\APM พกต้นแบบ config กับ mkcert.php ติดตัวไปด้วย จะได้เรนเดอร์ config ใหม่หรือออกใบรับรองเพิ่มทีหลังโดยไม่ต้องพึ่งโฟลเดอร์ build
  • LICENSE ถูกคัดลอกเป็น C:\APM\LICENSE.txt ใบอนุญาตของสคริปต์ build ที่แจกใน kit เป็น MIT พร้อมเงื่อนไข Commons Clause (ไฟล์เต็มอยู่ใน kit ชื่อ LICENSE) ตัวโปรแกรม Apache, PHP และ MariaDB ที่ build ออกมาใช้ใบอนุญาตของตัวเองตามต้นทาง
  • ไฟล์ข้อมูลของ Apache (mime.types, magic, charset.conv ฯลฯ) ถูกคัดลอกเหมือนเดิม ยกเว้น httpd.conf เพื่อไม่ทับไฟล์ที่คุณแก้เอง

ช่วงที่ 2: สร้างโฟลเดอร์ใช้งาน

www\default, logs\{apache,php,mariadb}, tmp\{sessions,uploads,opcache}, ssl, data ถ้ามีอยู่แล้ว New-Item -Force ไม่ทำอะไรเสียหาย ตัวใหม่คือ tmp\opcache ซึ่ง PHP ใช้เป็นที่เก็บไฟล์แคชของ OPcache (ดูเรื่อง ASLR ด้านล่าง) ข้อสังเกตที่เกี่ยวกับบั๊ก PidFile คือ ไม่มี apache\logs อยู่ในรายการนี้ โฟลเดอร์ log ของ Apache อยู่ที่ C:\APM\logs\apache ต่างหาก

ช่วงที่ 3: เรนเดอร์ config จาก templates

ฟังก์ชัน Render อ่านไฟล์จาก templates\ แทนค่า placeholder แล้วเขียนลงปลายทาง ตอนนี้มีหกตัว ได้แก่ {{APM_ROOT}}, {{HTTP_PORT}}, {{HTTPS_PORT}}, {{DB_PORT}} และสองตัวใหม่ {{BIND}}, {{CACHE_ID}} ข้อสำคัญคือ ถ้าปลายทางมีอยู่แล้วและไม่ได้ใส่ -ResetConfig มันพิมพ์ [KEEP] แล้วข้าม ไฟล์ที่คุณแก้เลยปลอดภัย ข้อควรจำที่ตามมาคือถ้าผมแก้ template ในรุ่นใหม่ของ kit config ที่ deploy ไปแล้วจะไม่ได้รับการแก้ตามอัตโนมัติ (บั๊ก PidFile, FcgidWrapper และ OPcache ด้านล่างคือตัวอย่าง) และ -Lan ที่มาทีหลังก็ไม่เปลี่ยน Listen ของ httpd.conf เดิมให้ ต้องคู่กับ -ResetConfig

  • APM_ROOT เป็น C:/APM (ทับตรง) สำหรับ Apache และ MariaDB ซึ่งรับ / ได้ แต่ส่วนของ php.ini ใช้ C:\APM แบบแบ็กสแลช (-WinPaths)
  • BIND คือ $Bind จากส่วนหัว ใช้ใน Listen ของ httpd.conf และ apm-ssl.conf เท่านั้น my.ini ของ MariaDB ไม่ใช้ตัวแปรนี้ และตายตัวที่ bind-address = 127.0.0.1 เสมอ
  • CACHE_ID คือชื่อเฉพาะของแคช OPcache ต่อหนึ่งโฟลเดอร์ติดตั้ง สคริปต์เอาพาธติดตั้ง (แปลง \ เป็น / และเป็นตัวพิมพ์เล็ก) ไปทำ SHA1 แล้วเอา 4 ไบต์แรกมาเป็นเลขฐานสิบหก 8 ตัว ต่อท้ายคำว่า apm ได้ชื่อยาว 11 ตัวอักษร พาธเดียวกันได้ชื่อเดิมทุกครั้ง พาธต่างกันได้ชื่อต่างกัน
  • เขียนไฟล์เป็น ascii ล้วน ไม่มี BOM และไม่เติมบรรทัดท้าย

ช่วงที่ 4: CA bundle ของ Mozilla

ดาวน์โหลด cacert.pem จาก curl.se ลง ssl\cacert.pem พร้อมเทียบ sha256 กับค่าที่เว็บเดียวกันประกาศ ถ้าไม่ตรงลบไฟล์ทิ้งแล้ว throw '[ERR] cacert.pem sha256 mismatch' ไฟล์นี้ให้ cURL และ OpenSSL ของ PHP เชื่อใบรับรองของเว็บภายนอก (ตั้งใน php.ini ที่ curl.cainfo และ openssl.cafile) ทำเฉพาะเมื่อยังไม่มีไฟล์ หรือสั่ง -ResetConfig

ช่วงที่ 5: CA ของเครื่องและใบรับรอง localhost

APM ไม่มี openssl.exe ให้ใช้ ผมเลยเขียน tools\mkcert.php ให้ใช้ extension openssl ของ PHP ที่เพิ่ง build ออกใบรับรองแทน สคริปต์เรียก php.exe ของ APM เอง ด้วย -n (ไม่โหลด php.ini เพราะ deploy ยังไม่เสร็จ) และ -d extension=openssl โหลดเฉพาะตัวที่ต้องใช้ ส่วนค่าของ -d extension_dir= ตอนนี้ครอบด้วยเครื่องหมายคำพูดอีกชั้นแล้ว (ดูบรรทัด & "$Target\php\php.exe" -n ... ในสคริปต์) เหตุผลอยู่ในเรื่องพาธมีวงเล็บด้านล่าง

เงื่อนไขของช่วงนี้เปลี่ยนไป รุ่นแรกออกใบรับรองเฉพาะเมื่อ localhost.crt ยังไม่มี รุ่นนี้ออกใหม่เมื่อ ยังไม่มีไฟล์ หรือสั่ง -ResetConfig หรือสั่ง -Lan เหตุผลคือ SAN ของใบรับรองต้องตรงกับวิธีที่เปิดเว็บ ถ้าเปิดทางชื่อเครื่องหรือ IP ของ LAN แล้วใบรับรองมีแต่ localhost browser ของเครื่องอื่นจะฟ้อง รายชื่อที่ใส่คือ localhost, 127.0.0.1, ::1 และเมื่อมี -Lan เพิ่มชื่อเครื่อง (ตัวพิมพ์เล็ก) กับ IP ใน $LanIPs

สิ่งที่ mkcert.php ทำ

  • สร้าง CA ชื่อ APM Local Dev CA อายุ 10 ปี ครั้งเดียวเท่านั้น (ถ้ามี apm-ca.key และ apm-ca.crt อยู่แล้วจะใช้ของเดิม) การออกใบรับรองเซิร์ฟเวอร์ใหม่เลยไม่ทำให้ browser ที่เชื่อ CA แล้วต้องนำเข้าอีก
  • ออกใบรับรองเซิร์ฟเวอร์ localhost.crt (ชื่อไฟล์มาจากโฮสต์ตัวแรก) อายุ 825 วัน SAN เป็นรายชื่อข้างบนทั้งหมด กุญแจเป็น EC (เส้นโค้ง prime256v1)
  • ใช้ไฟล์ตั้งค่า OpenSSL ที่เขียนขึ้นเองทั้งไฟล์ เพราะตอนแรกผมยืม openssl.cnf จาก deps แล้วเจอ Error loading extension section v3_ca ไฟล์นั้นไม่มี section ที่ต้องใช้
  • ใหม่: เช็กตั้งแต่ต้นว่า extension openssl โหลดได้จริง ถ้าไม่ได้พิมพ์ [ERR] PHP openssl extension not loaded (check -d extension_dir="..." - quote it, folder names may contain ( ) or spaces) แล้วออกด้วยรหัส 1 ข้อความนี้คือผลจากบั๊กพาธมีวงเล็บ
  • เมื่อใบรับรองใกล้หมดอายุ (825 วัน) วิธีที่ง่ายที่สุดตอนนี้คือ deploy.cmd -ResetConfig มันออกใบใหม่จาก CA เดิม (แต่ทับ config ที่แก้ไว้ด้วย) หรือจะลบ localhost.crt กับ localhost.key แล้วรัน deploy.cmd เฉยๆ ก็ได้ ข้อนี้ผมอนุมานจากตรรกะของสคริปต์ ยังไม่ได้ลองจนใบหมดอายุจริง

ช่วงที่ 6 และ 7: หน้าแรกและ datadir ของ MariaDB

คัดลอก templates\index.php เป็น www\default\index.php ถ้ายังไม่มี หน้านี้เปลี่ยนไปเล็กน้อยในรุ่นใหม่ (ดูหัวข้อ templates ข้างล่าง) จากนั้นถ้ายังไม่มี data\mysql\mysql (ตารางระบบ) จะรัน mariadb-install-db.exe --datadir=... --port=... --config=<my.ini> --silent ซึ่งเป็นการ bootstrap ไม่เปิดพอร์ต ได้ user root@localhost แบบไม่มีรหัสผ่าน เหมือน XAMPP

จุดที่คนมักพลาด: mariadb-install-db คัดลอก my.ini ไปไว้ใน data\mysql\my.ini และ ไฟล์นั้นคือตัวที่ server อ่านจริงตอนรัน (เหมือนที่ผมใช้ตอนสตาร์ตด้านล่าง) ไฟล์ C:\APM\mariadb\my.ini เป็นแค่ต้นแบบ รุ่นแรกของสคริปต์ไม่แตะไฟล์ใน datadir อีกเลยหลังสร้างครั้งแรก รุ่นนี้เพิ่มกิ่ง elseif ($ResetConfig) เข้ามา คือถ้า datadir มีอยู่แล้วและสั่ง -ResetConfig สคริปต์คัดลอก C:\APM\mariadb\my.ini (ที่เพิ่งเรนเดอร์ใหม่) ทับ data\mysql\my.ini แล้วพิมพ์ [OK] C:\APM\data\mysql\my.ini (synced) ผลคือเปลี่ยน -DbPort ด้วย -ResetConfig ได้จริง ข้อที่ต้องระวังคือถ้าคุณแก้ data\mysql\my.ini ด้วยมือไว้ การ sync จะทับการแก้นั้น

ช่วงที่ 8: ตรวจ syntax และสรุปผล

httpd.exe -t อ่าน config อย่างเดียวไม่เปิดพอร์ต ถ้ามี directive ผิดหรือ module โหลดไม่ได้จะล้มที่นี่ก่อนที่คุณจะได้สตาร์ต หลังจากนั้นรุ่นใหม่ทำเพิ่มอีกสองอย่าง

  • อ่านบรรทัด Listen ในไฟล์ httpd.conf ที่ใช้งานอยู่จริง (ไม่ใช่ค่าที่เพิ่งคำนวณ) แล้วพิมพ์ httpd.conf Listen: <ค่า> ให้เห็นว่า Apache จะฟังที่ไหน บรรทัดสรุปด้านบนก็เปลี่ยนจาก db :3307 เป็น db 127.0.0.1:3307 เพราะ MariaDB ฟังที่นั่นเสมอ
  • ถ้าสั่ง -Lan และพบว่า httpd.conf ที่ถูก [KEEP] ไว้ยังขึ้นต้นด้วย 127.0.0.1: จะพิมพ์ [WARN] httpd.conf was kept and still binds 127.0.0.1 - add -ResetConfig to switch it to LAN แล้วพิมพ์ URL ของ LAN ทุก IP พร้อมเตือนให้รัน scripts\firewall-lan.ps1 อย่างน้อยหนึ่งครั้งในฐานะ Administrator

ของที่เปลี่ยนใน templates\ ของ kit รุ่นใหม่

บั๊กที่ผมแก้ทั้งหมดในรอบนี้ไปลงที่ template สี่ไฟล์ ผมวางเฉพาะบรรทัดที่เปลี่ยน ที่เหลือของไฟล์เหมือนเดิมตามที่เล่าในตอนที่ 3, 4 และ 6

httpd.conf: PidFile และ Listen ที่เลือกได้

Define APM "{{APM_ROOT}}"
ServerRoot "${APM}/apache"
# Default is <ServerRoot>/logs/httpd.pid, but deploy keeps logs in ${APM}/logs -> without this line
# httpd exits right after start with AH00099 (could not create pid file)
PidFile "${APM}/logs/apache/httpd.pid"
Listen {{BIND}}:{{HTTP_PORT}}

PidFile ย้ายขึ้นมาอยู่บนสุดพร้อมคอมเมนต์อธิบาย (รุ่นแรกอยู่ท้ายไฟล์ใกล้ ErrorLog) ส่วน Listen เดิมเขียนตายตัวเป็น 127.0.0.1: ตอนนี้เป็น {{BIND}}: ค่าเริ่มต้นเลยยังเป็น 127.0.0.1:8080 เหมือนเดิม และเป็น 0.0.0.0:8080 เมื่อสั่ง -Lan

apm-ssl.conf: Listen ของ HTTPS

Listen {{BIND}}:{{HTTPS_PORT}}

หลักการเดียวกัน บรรทัดนี้คือบรรทัดที่สามของไฟล์ ถ้าแก้แค่ httpd.conf แต่ไม่แก้ไฟล์นี้ HTTP จะเปิด LAN แต่ HTTPS ยังอยู่ที่ 127.0.0.1 -ResetConfig เรนเดอร์ทั้งสองไฟล์พร้อมกันเลยไม่ต้องห่วง

httpd.conf: FcgidWrapper ที่มีเครื่องหมายคำพูดซ้อน

# inner \" quotes: mod_fcgid splits the wrapper command line on spaces (apr_tokenize_to_argv), so a path
# like "D:/APM test/php/php-cgi.exe" must be quoted inside the directive argument as well
FcgidWrapper "\"${APM}/php/php-cgi.exe\"" .php

รุ่นแรกเป็น FcgidWrapper "${APM}/php/php-cgi.exe" .php ธรรมดา ใช้ได้เมื่อพาธไม่มีช่องว่าง (C:\APM ไม่มี) เรื่องเต็มอยู่ในหัวข้อบั๊กด้านล่าง

php.ini: บล็อก OPcache ที่เพิ่ม

; Windows: processes with the same cache_id share one OPcache segment. Two APM folders running at once
; (or any other PHP of the same build) would collide -> "Opcode handlers are unusable due to ASLR" and
; every request 500s. A per-folder id keeps them apart; the file cache is the fallback if mapping fails.
opcache.cache_id = "{{CACHE_ID}}"
opcache.file_cache = "{{APM_ROOT}}\tmp\opcache"
opcache.file_cache_fallback = 1

สามบรรทัดนี้ต่อท้าย opcache.jit_buffer_size ในส่วน [opcache] ที่เหลือของบล็อก (memory_consumption = 512, jit = tracing ฯลฯ) ไม่เปลี่ยน

index.php: phpinfo เฉพาะเครื่องตัวเอง

// APM default page. Shows what is running; phpinfo() with ?info=1 (this machine only - APM may be on the LAN)
$isLocal = in_array($_SERVER['REMOTE_ADDR'] ?? '', ['127.0.0.1', '::1'], true);
if (isset($_GET['info']) && $isLocal) { phpinfo(); exit; }

และท้ายหน้ามีลิงก์ที่แสดงเฉพาะเมื่อ $isLocal เป็นจริง

<?php if ($isLocal): ?><p><a href="?info=1">phpinfo()</a></p><?php endif; ?>

เหตุผลคือ phpinfo() เปิดเผย path ของเครื่อง รายชื่อ extension และค่า config ทั้งหมด ซึ่งไม่ควรให้เครื่องอื่นใน LAN เห็นเมื่อเปิด -Lan เครื่องอื่นที่ขอ ?info=1 จะได้หน้าปกติแทน (ผมทดสอบแล้ว ผลอยู่ในหัวข้อ LAN)

หมายเหตุเรื่องไฟล์: index.php คัดลอกไป www\default เฉพาะตอนที่ยังไม่มีไฟล์ ใครที่ deploy ไปแล้วจะยังได้หน้าแรกรุ่นเก่า ถ้าต้องการรุ่นใหม่ให้คัดลอกทับเอง Copy-Item C:\APM\templates\index.php C:\APM\www\default\index.php -Force (ไฟล์ต้นทางอยู่ใน C:\APM\templates เพราะ deploy รุ่นใหม่ mirror มาให้แล้ว)

รัน deploy.cmd และดูของที่ได้

ขั้นที่ 5: สั่ง deploy

ที่ C:\apm-build ใน pwsh ต้องมี .\ นำหน้า (PowerShell ไม่รันไฟล์จากโฟลเดอร์ปัจจุบันถ้าไม่ระบุ)

.\deploy.cmd

สคริปต์ใช้เวลาไม่นานและพิมพ์สถานะเป็นบรรทัดๆ ผมไม่ได้จับเวลาขั้นนี้ ถ้าต้องการพอร์ตอื่นใส่ตัวเลือกต่อท้าย เช่น .\deploy.cmd -HttpPort 8081 ส่วนการเปิดให้เครื่องอื่นใน LAN เข้า (-Lan) แยกไปอยู่ในหัวข้อของมันด้านล่าง ขั้นนี้ยังเป็นแบบ local อย่างเดียว

ขั้นที่ 6: อ่านผลที่ควรเห็น (deploy ครั้งแรก)

[OK] apache -> C:\APM\apache
[OK] php -> C:\APM\php
[OK] mariadb -> C:\APM\mariadb
[OK] C:\APM\apache\conf\httpd.conf
[OK] C:\APM\apache\conf\extra\apm-ssl.conf
[OK] C:\APM\php\php.ini
[OK] C:\APM\mariadb\my.ini
[OK] C:\APM\ssl\cacert.pem
[OK] new CA: C:\APM\ssl/apm-ca.crt
[OK] cert C:\APM\ssl/localhost.crt  SAN=DNS:localhost,IP:127.0.0.1,IP:::1
[OK] datadir C:\APM\data\mysql (root@localhost, no password)
Syntax OK
[OK] APM deployed to C:\APM  (http :8080, https :8443, db 127.0.0.1:3307) - not started
     httpd.conf Listen: 127.0.0.1:8080

บล็อกนี้ผมเอาผลจริงที่บันทึกจาก deploy รุ่นแรก มาปรับสามจุดตามโค้ดของรุ่นใหม่ คือ SAN ที่มี IP:::1 เพิ่ม, db 127.0.0.1:3307 ในบรรทัดสรุป และบรรทัด httpd.conf Listen: ท้ายสุด ผมยังไม่ได้ deploy ซ้ำด้วยรุ่นนี้บนเครื่องว่างเพื่อเทียบตัวอักษรต่อตัวอักษร ถ้าบรรทัดของคุณต่างจากนี้เล็กน้อยให้ดูความหมายของมัน

ข้อสังเกต: พาธของ CA แสดงเป็น C:\APM\ssl/apm-ca.crt มี / ปนอยู่ ไม่ผิด เป็นแค่รูปแบบการต่อสตริงของ mkcert.php ส่วนบรรทัดสรุปต้องเขียนว่า not started เสมอ เพราะสคริปต์ไม่เปิดอะไรทั้งนั้น และ httpd.conf Listen: ต้องเป็น 127.0.0.1:8080 ตราบที่คุณไม่ได้ใช้ -Lan

ขั้นที่ 7: ตรวจโฟลเดอร์ที่ได้

Get-ChildItem C:\APM -Name

ผลที่ควรเห็นต้องมีชื่อเหล่านี้ (ลำดับอาจต่างเล็กน้อย)

apache
data
LICENSE.txt
logs
mariadb
php
ssl
templates
tmp
tools
versions.json
www

โครงสร้างและขนาดจริงจากเครื่องผม (ขนาดวัดจากรุ่นแรก บรรทัดที่เพิ่มใหม่ไม่ได้ทำให้ขนาดรวมเปลี่ยนในระดับที่ผมสังเกตได้ และผมไม่ได้วัดซ้ำ)

C:\APM\
  apache\    20 MB   bin\ modules\ conf\ error\ icons\
  php\      124 MB   php.exe php-cgi.exe ext\ + DLL ของ deps และ ImageMagick, php.ini
  mariadb\  201 MB   bin\ lib\plugin\ share\ my.ini
  data\     302 MB   mysql\ (datadir + my.ini ที่ server ใช้ตอนรันจริง)
  ssl\               apm-ca.crt/.key, localhost.crt/.key, cacert.pem
  www\default\       index.php
  templates\  tools\ ต้นแบบ config กับ mkcert.php (คัดลอกมาจาก kit)
  logs\  tmp\        tmp\ มี sessions\ uploads\ opcache\
  LICENSE.txt        MIT + Commons Clause ของสคริปต์ build
  versions.json      เวอร์ชันที่ติดตั้งอยู่

ตรวจให้แน่ใจอีกข้อว่า plugin ของ MariaDB มาครบ (ข้อนี้คือบั๊กที่รุ่นแรกพลาด)

Get-ChildItem C:\APM\mariadb\lib\plugin -Filter *.dll | Measure-Object | Select-Object -ExpandProperty Count

ผลที่ควรเห็นคือตัวเลขมากกว่า 0 ผมไม่ได้บันทึกจำนวนไฟล์ที่แน่นอน ถ้าคำสั่งฟ้องว่าไม่พบพาธหรือได้ 0 แปลว่ายังใช้ kit รุ่นเก่า ให้ดูหัวข้อบั๊กด้านล่าง

deploy.cmd คัดลอกทับเฉพาะ binary ไม่แตะของที่คุณแก้robocopy /MIRstage\apacheตัด .pdb .lib .expstage\phpตัด .pdb .lib .expstage\mariadbตัด .pdb .lib .expC:\APM\apacheไม่ทับ conf\ และ logs\C:\APM\phpไม่ทับ php.iniC:\APM\mariadbเก็บ my.ini และ lib\plugintemplates\ -> ไฟล์ confighttpd.conf · apm-ssl.conf · php.ini · my.iniแทนค่า BIND และ CACHE_ID ด้วยสร้างเฉพาะไฟล์ที่ยังไม่มี ถ้ามีแล้วขึ้น [KEEP]-ResetConfig = เขียนทับทั้งหมด+ ออก cert ใหม่ + sync data\mysql\my.iniสร้างครั้งแรกครั้งเดียวssl\: CA + localhost.crt (mkcert.php)ssl\cacert.pem · www\default\index.phpdata\mysql (mariadb-install-db)คัดลอกทุกรอบ: templates\ tools\LICENSE.txt · สร้าง tmp\opcache
การ deploy ซ้ำเป็นการอัปเกรด binary ในที่เดิม config, data, www, ssl และ logs ยังอยู่ครบ ส่วน templates, tools และ LICENSE.txt ถูกคัดลอกใหม่ทุกรอบ

เมื่อรัน deploy ซ้ำ จะเห็นอะไร

การรันซ้ำคือวิธีอัปเกรด สิ่งที่ต่างจากครั้งแรกคือบรรทัด config กลายเป็น [KEEP] และไม่มีบรรทัด CA, cert หรือ datadir เพราะมีอยู่แล้ว (ผลจากตรรกะของสคริปต์ที่อ่านข้างบน)

[OK] apache -> C:\APM\apache
[OK] php -> C:\APM\php
[OK] mariadb -> C:\APM\mariadb
[KEEP] C:\APM\apache\conf\httpd.conf
[KEEP] C:\APM\apache\conf\extra\apm-ssl.conf
[KEEP] C:\APM\php\php.ini
[KEEP] C:\APM\mariadb\my.ini
Syntax OK
[OK] APM deployed to C:\APM  (http :8080, https :8443, db 127.0.0.1:3307) - not started
     httpd.conf Listen: 127.0.0.1:8080
-ResetConfig ทับของที่คุณแก้

-ResetConfig เขียน httpd.conf, apm-ssl.conf, php.ini, my.ini (ในโฟลเดอร์ mariadb) ใหม่จาก template ดาวน์โหลด cacert.pem ใหม่ ออกใบรับรองของเซิร์ฟเวอร์ใหม่ (CA เดิมยังอยู่) และ คัดลอก my.ini ใหม่ทับ data\mysql\my.ini ให้ด้วย ข้อมูลฐานข้อมูลไม่ถูกแตะ ก่อนใช้ ให้คัดลอกไฟล์ config ที่คุณแก้ไว้เก็บก่อน รวมถึง data\mysql\my.ini ถ้าคุณเคยแก้ด้วยมือ การเปลี่ยน -DbPort ภายหลังใช้ได้แล้วด้วยคำสั่งเดียว .\deploy.cmd -DbPort 3306 -ResetConfig (ต้องหยุด APM ก่อน) ข้อสรุปนี้มาจากการอ่านสคริปต์ ผมยังไม่ได้ทดสอบการเปลี่ยนพอร์ตหลัง deploy ครบวงจร

ให้ browser เชื่อ CA ออกใบรับรองเพิ่ม และใส่ PATH

ใบรับรองของ https://localhost:8443 ออกโดย CA ที่เครื่องคุณสร้างเอง browser ยังไม่เชื่อ เลยฟ้องเตือนจนกว่าจะนำเข้า CA นี้ ทำครั้งเดียวต่อ user มีสองทาง ทางแรกเป็นสคริปต์บรรทัดเดียว (ที่ผมแนะนำ) ทางที่สองเป็นการคลิกเองใน Windows ผลเหมือนกัน

ขั้นที่ 8: ทางลัดด้วย trust-ca.ps1 (แนะนำ)

เปิด pwsh แบบ user ปกติ (ไม่ต้อง admin) แล้วรันจากโฟลเดอร์ kit เพราะ scripts\ ไม่ได้ถูกคัดลอกไป C:\APM

pwsh -NoProfile -File C:\apm-build\scripts\trust-ca.ps1

สคริปต์อ่าน C:\APM\ssl\apm-ca.crt (ถ้าไม่มีจะหยุดด้วย [ERR] C:\APM\ssl\apm-ca.crt missing - run deploy.cmd first) เปิดที่เก็บ Trusted Root Certification Authorities ของ user ปัจจุบัน แล้วเพิ่ม CA เข้าไปถ้ายังไม่มี thumbprint นี้ Windows จะเด้งหน้าต่างเตือนความปลอดภัยถามยืนยันหนึ่งครั้ง ให้กด Yes เหมือนที่ทำมือในขั้นที่ 12 ด้านล่าง สิ่งที่พิมพ์ตอนสำเร็จมีรูปแบบนี้ (ชื่อ, thumbprint และวันหมดอายุเป็นของ CA ที่เครื่องคุณสร้าง)

[OK] trusted: <ชื่อของ CA> (<thumbprint>), valid until <yyyy-MM-dd>

ถ้ารันซ้ำจะได้ [OK] already trusted: ... และถ้าอยากเลิกเชื่อ ใส่ -Remove ท้ายคำสั่ง จะพิมพ์ [OK] removed ... (หรือ [OK] not trusted - nothing to remove) ตัวเลือก -Target ใช้ชี้โฟลเดอร์ติดตั้งอื่นนอกจาก C:\APM

ตรวจว่า CA เข้าที่เก็บแล้ว

Get-ChildItem Cert:\CurrentUser\Root | Where-Object Subject -like '*APM Local Dev CA*'

ผลที่ควรเห็นคือหนึ่งแถวที่มี thumbprint ตรงกับที่สคริปต์พิมพ์ จากนั้นปิดแล้วเปิด browser ใหม่ แล้วเข้า https://localhost:8443/ ตอนที่ Apache รันแล้ว (ดูขั้นที่ 18) ต้องไม่ขึ้นคำเตือน

หมายเหตุเรื่อง Firefox: ตามคอมเมนต์ในสคริปต์ Firefox อ่านที่เก็บของ Windows ผ่านค่า security.enterprise_roots ซึ่งเปิดเป็นค่าเริ่มต้นใน Firefox รุ่นปัจจุบัน ผมไม่ได้ทดสอบกับ Firefox เอง ถ้ายังขึ้นคำเตือนให้นำเข้าในหน้า Settings ของ Firefox แยกต่างหาก

เครื่องอื่นใน LAN: คัดลอกเฉพาะ apm-ca.crt ไปที่เครื่องนั้น (ไม่ใช่ apm-ca.key เด็ดขาด) แล้วดับเบิลคลิกนำเข้าตามขั้นที่ 9 ถึง 12 ด้านล่าง

ทางเลือก: นำเข้าเองด้วยมือ (ขั้นที่ 9 ถึง 12)

ใครไม่อยากรันสคริปต์ หรืออยากเห็นว่า Windows ทำอะไร ใช้ขั้นเหล่านี้แทน

ขั้นที่ 9: เปิดไฟล์ CA

ใน File Explorer ไปที่โฟลเดอร์ C:\APM\ssl ดับเบิลคลิกไฟล์ apm-ca.crt จะเห็นหน้าต่างรายละเอียดใบรับรอง ชื่อผู้ออกคือ APM Local Dev CA

ขั้นที่ 10: เริ่มติดตั้ง

กดปุ่ม Install Certificate... เลือก Current User แล้วกด Next

ขั้นที่ 11: เลือกที่เก็บให้ถูก

เลือก Place all certificates in the following store กด Browse เลือก Trusted Root Certification Authorities กด OK แล้ว Next และ Finish

ขั้นที่ 12: ยืนยันคำเตือนของ Windows

Windows จะถามว่าจะติดตั้ง CA นี้จริงหรือไม่ กด Yes หน้าต่างสุดท้ายแจ้งว่านำเข้าสำเร็จ

ผลที่ควรเห็นคือ ตอนที่ Apache รันแล้ว การเปิด https://localhost:8443/ ใน browser (Edge หรือ Chrome) ไม่ขึ้นคำเตือนใบรับรอง ผมยืนยันขั้นนี้ด้วยการทดสอบแบบข้ามการตรวจใบรับรอง (curl -k) ในหัวข้อถัดไป แต่ไม่ได้บันทึกผลของ browser ที่เชื่อ CA แล้ว

รักษากุญแจ CA ให้ดี

ไฟล์ C:\APM\ssl\apm-ca.key คือกุญแจที่ออกใบรับรองให้เว็บไหนก็ได้ที่ browser ของคุณเชื่อ ห้ามคัดลอกออกจากเครื่อง ห้ามแชร์โฟลเดอร์ ssl และห้ามนำ apm-ca.crt ไปติดตั้งบนเครื่องของคนอื่นโดยไม่บอกเขา

ขั้นที่ 13: ออกใบรับรองเพิ่มสำหรับ vhost ด้วย new-cert.ps1

ถ้าคุณทำหลายเว็บบนเครื่องเดียวและอยากให้แต่ละเว็บมี HTTPS ของตัวเอง ออกใบรับรองเพิ่มจาก CA เดิมได้ ไม่ต้องนำเข้า CA ใหม่

pwsh -NoProfile -File C:\apm-build\scripts\new-cert.ps1 -Hosts myapp.localhost

สคริปต์เรียก mkcert.php ตัวเดียวกับที่ deploy ใช้ ผ่าน php.exe ของ C:\APM ใส่หลายชื่อได้โดยคั่นด้วยจุลภาค เช่น -Hosts myapp.localhost,myapp.test ชื่อแรกใช้ตั้งชื่อไฟล์ ผลที่ได้คือไฟล์ C:\APM\ssl\myapp.localhost.crt กับ .key และบรรทัดสถานะตามโค้ดของ mkcert.php

[OK] cert C:\APM\ssl/myapp.localhost.crt  SAN=DNS:myapp.localhost

ชื่อที่ลงท้าย .localhost ใช้สะดวกที่สุด เพราะ Chrome, Edge และ Firefox แปลงชื่อพวกนี้เป็น 127.0.0.1 เองโดยไม่ต้องแก้ไฟล์ hosts ส่วนชื่ออื่นอย่าง .test ต้องเพิ่มบรรทัดใน C:\Windows\System32\drivers\etc\hosts เอง ถ้าใส่ชื่อซ้ำกับของเดิมไฟล์ .crt และ .key ของชื่อนั้นจะถูกเขียนทับ

เอาไปใช้ใน vhost โดยสร้างไฟล์ใหม่ในโฟลเดอร์ C:\APM\apache\conf\vhosts (httpd.conf มี IncludeOptional conf/vhosts/*.conf อยู่แล้ว) ตัวอย่างข้างล่างผมเขียนตามรูปแบบของ apm-ssl.conf ยังไม่ได้ทดสอบ สองบรรทัด SSLCertificate... มาจากคอมเมนต์ในสคริปต์

<VirtualHost *:8443>
    ServerName myapp.localhost
    DocumentRoot "${APM}/www/myapp"
    SSLEngine on
    SSLCertificateFile    "${APM}/ssl/myapp.localhost.crt"
    SSLCertificateKeyFile "${APM}/ssl/myapp.localhost.key"
</VirtualHost>

เรียก C:\APM\apache\bin\httpd.exe -t ตรวจก่อนสตาร์ตเสมอ

ขั้นที่ 14: ใส่ php กับ mariadb ลง PATH ด้วย set-path.ps1

ถ้าอยากพิมพ์ php -v หรือ mariadb ได้จากทุกที่ (และให้ VS Code, composer, artisan เจอ) ใส่โฟลเดอร์ของ APM ลง PATH ได้ด้วยสคริปต์นี้ มีสองระดับ

pwsh -NoProfile -File C:\apm-build\scripts\set-path.ps1 -Scope User

-Scope User ไม่ต้อง admin แก้เฉพาะ PATH ของคุณ ถ้าไม่ใส่ตัวเลือก จะแก้ PATH ของทั้งเครื่อง ต้องเปิด pwsh แบบ Administrator (ถ้าไม่ใช่ admin จะหยุดด้วย [ERR] Machine scope needs Administrator (or use -Scope User)) สคริปต์ทำสามอย่าง คือ ลบรายการ C:\xampp\... ออกจาก PATH, ใส่ C:\APM\php กับ C:\APM\mariadb\bin ไว้ตรงตำแหน่งที่รายการ XAMPP รายการแรกเคยอยู่ (ลำดับความสำคัญเทียบกับโปรแกรมอื่นเลยไม่เปลี่ยน) แล้วแจ้ง Windows ว่า environment เปลี่ยนแล้ว ผลที่พิมพ์

[OK] User PATH updated (REG_EXPAND_SZ)
     - <รายการที่ถูกถอดออก>
     + C:\APM\php
     + C:\APM\mariadb\bin
     restart VS Code / terminals to pick it up

เตือนไว้ก่อน: ถ้าคุณยังใช้ XAMPP คู่กัน การรันสคริปต์นี้ถอด php ของ XAMPP ออกจาก PATH ด้วย ใช้ -Remove เมื่ออยากเอารายการของ APM ออกอีกที โปรแกรมที่เปิดค้างอยู่ (VS Code, terminal) ต้องปิดแล้วเปิดใหม่ถึงจะเห็น

ตรวจว่าใช้ได้ในหน้าต่าง pwsh ใหม่

php -v

บรรทัดแรกต้องเป็น PHP 8.4.27 (cli) ... ตามรุ่นที่คุณ build

เรื่องที่ผมพลาดกับ PATH: ทำไมสคริปต์แก้ registry ตรงๆ

ตอนแรกผมแก้ PATH ด้วย [Environment]::SetEnvironmentVariable ปรากฏว่ามันขยาย %SystemRoot% เป็น C:\WINDOWS แล้วเขียนกลับเป็น REG_SZ ถาวร ค่า PATH ใน registry ควรเป็นชนิด REG_EXPAND_SZ ที่เก็บข้อความดิบอย่าง %SystemRoot%\system32 ไว้ให้ Windows ขยายตอนใช้ พอกลายเป็น REG_SZ ข้อความธรรมดา รายการพวกนั้นก็ถูกแทนด้วยค่าตายตัวไปตลอด ไม่ขยายตามเครื่องอีก ตอนนี้ set-path.ps1 เปิด registry key ตรงๆ อ่านค่าด้วยตัวเลือก DoNotExpandEnvironmentNames เพื่อได้ข้อความดิบ แก้เฉพาะรายการของเรา แล้วเขียนกลับเป็น REG_EXPAND_SZ ใครเขียนสคริปต์แก้ PATH เองให้ระวังจุดนี้

ตรวจชนิดของค่าหลังรัน (ถ้าใช้ -Scope User)

(Get-Item -Path HKCU:\Environment).GetValueKind('Path')

ผลที่ควรเห็นคือ ExpandString ซึ่งคือ REG_EXPAND_SZ ผมระบุชื่อชนิดตามเอกสารของ .NET ไม่ได้บันทึกผลจริงจากคำสั่งนี้

สตาร์ตครั้งแรก MariaDB Apache ทดสอบ แล้วปิด

ส่วนนี้ใช้ผลที่ผมวัดจริงเมื่อ 2026-10-22 บน Windows 11 x64 ทั้งหมด หน้าต่างที่ใช้คือ pwsh ตัวใหม่ (user ปกติ) โฟลเดอร์ไหนก็ได้ เพราะทุกคำสั่งใช้พาธเต็ม

เส้นทางของหนึ่ง request ใน APMBrowsercurl.exehttp / httpshttpd.exe:8080 http:8443 httpsmod_fcgidFcgidWrapperเรียก php-cgiphp-cgi.exePHP 8.4.27NTS + OPcachemariadbd.exeport 3307mysqlndไฟล์ตั้งค่า:conf\httpd.confphp\php.inidata\mysql\my.inilog: logs\apache · logs\php · logs\mariadbค่าเริ่มต้นฟังที่ 127.0.0.1 เท่านั้น (ใส่ -Lan เปิดเฉพาะ Apache ให้ LAN)สตาร์ต: MariaDB ก่อน แล้วค่อย Apache (หน้าแรกจะแสดงเวอร์ชัน MariaDB)หยุด: Apache ก่อน (httpd + php-cgi) แล้วค่อย shutdown MariaDB
PHP ไม่ได้ฝังใน Apache ใช้ mod_fcgid เรียก php-cgi.exe เป็น process แยก ใกล้เคียงกับ php-fpm บน Linux ที่สุดเท่าที่ Windows ทำได้

ขั้นที่ 15: สตาร์ต MariaDB

Start-Process C:\APM\mariadb\bin\mariadbd.exe -ArgumentList '--defaults-file=C:\APM\data\mysql\my.ini'

คำสั่งคืน prompt ทันทีโดยไม่พิมพ์อะไร อาจมีหน้าต่างคอนโซลของ mariadbd โผล่ขึ้นมา อย่าปิดหน้าต่างนั้น เพราะการปิดคือการฆ่า server ใช้ --defaults-file ชี้ไปที่ my.ini ใน datadir ตามที่อธิบายในช่วงที่ 7

ขั้นที่ 16: รอให้ MariaDB พร้อม

Start-Sleep 6

หกวินาทีพอสำหรับ datadir ที่เพิ่งสร้างบนเครื่องผม ถ้าเครื่องช้ากว่านี้ให้รอนานขึ้นก่อนทำขั้นถัดไป

ขั้นที่ 17: ถามเวอร์ชันและค่า collation

C:\APM\mariadb\bin\mariadb.exe -uroot --port=3307 --protocol=tcp -e "select version(), @@collation_server, @@have_ssl, @@bind_address"

ผลที่ควรเห็น (ผลจริง)

version()            @@collation_server    @@have_ssl   @@bind_address
11.8.9-MariaDB-log   utf8mb4_unicode_ci    YES          127.0.0.1

อ่านผลทีละคอลัมน์: เวอร์ชัน 11.8.9, collation เป็น utf8mb4_unicode_ci ตามที่ล็อกไว้ใน my.ini (ตอนที่ 4 อธิบายเหตุผล), have_ssl เป็น YES แปลว่า wolfSSL ที่มากับ source ใช้ได้, และ bind อยู่ที่ 127.0.0.1 ตามต้องการ

ขั้นที่ 18: สตาร์ต Apache

Start-Process C:\APM\apache\bin\httpd.exe

คืน prompt ทันทีเช่นกัน ถ้าคุณใช้ kit รุ่นล่าสุด Apache จะขึ้นปกติ ถ้าไม่ขึ้นและเคย deploy ด้วย template รุ่นเก่า ให้ข้ามไปอ่านเรื่อง PidFile ด้านล่าง

ขั้นที่ 19: ทดสอบ HTTP

curl.exe -s -o NUL -w "%{http_code} %{content_type}`n" http://127.0.0.1:8080/

ผลที่ควรเห็น

200 text/html; charset=UTF-8

200 แปลว่า Apache ตอบ และ text/html; charset=UTF-8 ที่ได้จากหน้า index.php แปลว่า PHP ผ่าน mod_fcgid ทำงานแล้วด้วย (ไม่ใช่ไฟล์ static)

ขั้นที่ 20: ทดสอบ HTTPS

curl.exe -sk -o NUL -w "%{http_code}`n" https://localhost:8443/

ผลที่ควรเห็น

200

-k บอก curl ให้ข้ามการตรวจใบรับรอง ผลนี้เลยยืนยันแค่ว่า TLS ทำงานและ Apache ตอบ ไม่ได้ยืนยันว่า CA ถูกเชื่อ ถ้านำเข้า CA แล้วและอยากตรวจโดยไม่ข้ามการตรวจ ให้ลบ -k ออก ควรได้ 200 เหมือนกัน แต่ผมไม่ได้บันทึกผลของการรันแบบนั้นไว้

ขั้นที่ 21: เปิดหน้าแรกใน browser

เปิด http://127.0.0.1:8080/ ในเบราว์เซอร์ คุณจะเห็นตารางสั้นๆ ที่ผม (ในฐานะผู้เขียนหน้านี้) ให้บอกสิ่งที่รันอยู่

แถว ค่าที่ผมเห็น
Server Apache (ServerTokens Prod ตัดเลขเวอร์ชันออก)
Protocol HTTP/1.1 และต่อท้ายด้วย TLS เมื่อเข้าทาง https
PHP 8.4.27 cgi-fcgi, OPcache JIT on
MariaDB เลขเวอร์ชันของ MariaDB ถ้ารันอยู่ หรือ not reachable (...) ถ้ายังไม่ได้สตาร์ต
Extensions รายชื่อ extension ที่โหลด รวม igbinary redis imagick gd intl mbstring opcache

ลิงก์ phpinfo() ใต้ตารางแสดงเฉพาะเมื่อเปิดหน้านี้จากเครื่อง APM เอง (127.0.0.1 หรือ ::1) ถ้าแถว MariaDB ขึ้น not reachable ทั้งที่สตาร์ตแล้ว ดูตารางปัญหาท้ายตอน หน้าแรกเชื่อมต่อด้วย user root ไม่มีรหัสผ่านที่พอร์ตของ mysqli.default_port ใน php.ini ถ้าคุณตั้งรหัสผ่านให้ root ภายหลัง แถวนี้จะรายงานว่าเข้าไม่ได้ ซึ่งเป็นพฤติกรรมที่คาดไว้

ขั้นที่ 22: ดู phpinfo

เปิด http://127.0.0.1:8080/?info=1 ได้หน้า phpinfo() เต็ม (รุ่นใหม่ของ index.php ให้เฉพาะเครื่อง APM เอง ผ่าน 127.0.0.1 หรือ ::1 เครื่องอื่นที่ขอ ?info=1 ได้หน้าปกติ) ใช้ดูว่าไฟล์ ini ที่ถูกโหลดคือไฟล์ของ APM และดูรายละเอียด extension หน้า header ที่ Apache ส่งกลับ มีแค่ Server: Apache ไม่มีเลขเวอร์ชัน

เรื่องที่ผมเจอตอนสตาร์ต Apache ครั้งแรก

ตอนที่ผมทดสอบรอบแรก ผมสั่ง Start-Process httpd.exe แล้ว curl ไปที่พอร์ต 8080 ไม่ได้อะไรกลับมา ตรวจ process พบว่า httpd ตายทันทีหลังสตาร์ต ไม่มีหน้าต่าง error ให้เห็น เพราะ Start-Process ซ่อนทุกอย่าง

ผมรัน C:\APM\apache\bin\httpd.exe ตรงๆ ในคอนโซล และเห็นบรรทัดนี้

AH00099: could not create C:/APM/apache/logs/httpd.pid

สาเหตุอยู่ที่ช่วงที่ 2 ของสคริปต์ deploy ที่ผมชี้ให้ดูข้างบน Apache ใช้ค่าเริ่มต้นของ PidFile คือ logs/httpd.pid เทียบกับ ServerRoot (C:/APM/apache) แต่สคริปต์ deploy สร้างโฟลเดอร์ log ที่ C:\APM\logs\apache ไม่ได้สร้าง C:\APM\apache\logs Apache เลยเขียน pid file ไม่ได้และปฏิเสธจะสตาร์ต ผมไม่ได้ตรวจว่าบรรทัดนี้ถูกเขียนลง error.log ด้วยหรือไม่ เห็นแน่ๆ แค่ในคอนโซล

ทางแก้ที่ผมใส่ไว้ใน kit คือบอก Apache ชัดๆ ใน templates\httpd.conf ว่า pid file อยู่ในโฟลเดอร์ log ที่มีจริง

PidFile "${APM}/logs/apache/httpd.pid"
ErrorLog "${APM}/logs/apache/error.log"

kit ที่คุณดาวน์โหลดมีบรรทัดนี้แล้ว แต่ ถ้าคุณ deploy ไปก่อนด้วย template เก่า ไฟล์ httpd.conf ใน C:\APM ถูก [KEEP] ไว้ ไม่ได้รับการแก้ตาม ให้ตรวจและแก้ตามขั้นเหล่านี้

ขั้นที่ 23: ตรวจว่าไฟล์ของคุณมีบรรทัด PidFile หรือยัง

Get-Content C:\APM\apache\conf\httpd.conf | Select-String -Pattern '^PidFile'

ถ้ามีบรรทัดนี้โผล่ แปลว่าแก้แล้ว ไม่ต้องทำต่อ

PidFile "${APM}/logs/apache/httpd.pid"

ถ้าไม่มีอะไรพิมพ์ออกมา ให้เลือกทางแก้ทางใดทางหนึ่งในสองขั้นถัดไป

ขั้นที่ 24 (ทางเลือก ก): เติมบรรทัดเอง

เปิดไฟล์ด้วย Notepad

notepad C:\APM\apache\conf\httpd.conf

หาบรรทัด ErrorLog "${APM}/logs/apache/error.log" แล้วเพิ่มบรรทัด PidFile "${APM}/logs/apache/httpd.pid" ไว้เหนือมัน บันทึกไฟล์ แล้วตรวจ syntax

C:\APM\apache\bin\httpd.exe -t

ผลที่ควรเห็น

Syntax OK

ขั้นที่ 25 (ทางเลือก ข): ให้ deploy เรนเดอร์ config ใหม่

ทางนี้ง่ายกว่าแต่ ทับ config ที่คุณแก้ไว้ทั้งสี่ไฟล์ ให้สำรองก่อนถ้ามีการแก้ จากนั้นรันที่ C:\apm-build (ใช้ได้เมื่อไม่มี process ของ APM รันอยู่)

.\deploy.cmd -ResetConfig

หลังทำเสร็จ ย้อนกลับไปทำขั้นที่ 18 (สตาร์ต Apache) อีกครั้ง

หยุดทุกอย่างให้เรียบร้อย

ขั้นที่ 26: หยุด Apache

Apache ที่สตาร์ตด้วย Start-Process ไม่มีหน้าต่างให้กด Ctrl+C ต้องหยุดด้วยคำสั่ง (ถ้าคุณรัน httpd.exe ในคอนโซลของตัวเอง ใช้ Ctrl+C ในหน้าต่างนั้นได้เลย)

Get-Process httpd | Where-Object Path -like 'C:\APM\*' | Stop-Process

คำสั่งนี้ไม่พิมพ์อะไรเมื่อสำเร็จ กรองด้วย Path เพื่อไม่ไปฆ่า httpd ของโปรแกรมอื่น เช่น XAMPP ที่อาจรันอยู่ การเรียกให้เจอ process มากกว่าหนึ่งตัวเป็นเรื่องปกติของ Apache บน Windows (มีตัวแม่กับตัวลูก) คำสั่งนี้หยุดทุกตัว

ขั้นที่ 27: หยุด php-cgi ที่ค้าง

Get-Process php-cgi -ErrorAction SilentlyContinue | Where-Object Path -like 'C:\APM\*' | Stop-Process

mod_fcgid เป็นคนสร้าง php-cgi.exe ไว้รอรับงาน เมื่อ Apache ตายมันอาจค้างอยู่ได้ ถ้าไม่มี process ให้หยุด คำสั่งนี้ไม่พิมพ์อะไร

ขั้นที่ 28: ปิด MariaDB อย่างถูกวิธี

C:\APM\mariadb\bin\mariadb-admin.exe -uroot --port=3307 --protocol=tcp shutdown

คำสั่งนี้ไม่พิมพ์อะไรเมื่อสำเร็จ และให้ MariaDB flush ข้อมูลลงดิสก์ก่อนจบ ดีกว่าการฆ่า process ทิ้ง หน้าต่างคอนโซลของ mariadbd จะปิดเอง

ขั้นที่ 29: ยืนยันว่าไม่มีอะไรค้าง

Get-Process httpd,php-cgi,mariadbd -ErrorAction SilentlyContinue | Where-Object { $_.Path -like 'C:\APM\*' }

ผลที่ควรเห็นคือไม่มีอะไรพิมพ์ออกมา ตอนนี้ APM ปิดสนิท พร้อมสำหรับ deploy.cmd รอบถัดไป

ตัวควบคุมแบบ service

ซีรีส์นี้ครอบคลุมการ build และ deploy ส่วนตัวควบคุมสำหรับ start, stop และติดตั้งเป็น Windows Service (apmctl) ผมจะเขียนแยกเป็นบทความต่างหากภายหลัง ตอนนี้ยังไม่มีคำสั่งของ apmctl ให้ใช้ ใช้วิธีสตาร์ตและหยุดด้วยมือตามที่เขียนไว้ข้างบน

เปิดให้เครื่องใน LAN เข้า

ค่าเริ่มต้นของ APM คือฟังที่ 127.0.0.1 ล้วน มีแต่เครื่องที่ติดตั้งเท่านั้นที่เข้าได้ ซึ่งตรงกับจุดประสงค์ของเครื่องพัฒนา แต่บางทีผมอยากเปิดเว็บที่กำลังทำให้เพื่อนร่วมทีมในออฟฟิศเดียวกันดู หรือเปิดจากมือถือที่ต่อ Wi-Fi เดียวกัน kit รุ่นใหม่เลยมีตัวเลือก -Lan ที่เปิดเฉพาะ Apache ให้ LAN เข้า และคง MariaDB ไว้ที่ 127.0.0.1 เสมอ

ใช้ในเครือข่ายที่คุณเชื่อถือเท่านั้น

เปิด -Lan แล้วทุกเครื่องในซับเน็ตเดียวกันเปิดเว็บใน C:\APM\www ได้ ถ้าเว็บที่คุณวางไว้มีหน้า admin หรือข้อมูลจริง ใครในวงเดียวกันก็เห็น อย่าเปิดในเครือข่ายสาธารณะ (ร้านกาแฟ โรงแรม) และอย่า port forward จากเราเตอร์ออกอินเทอร์เน็ต APM เป็นเครื่องพัฒนา ไม่ใช่เซิร์ฟเวอร์ production

ด้วย -Lan ผ่านได้แค่ Apache MariaDB ปิดเสมอPC อื่นใน LANซับเน็ตเดียวกันbrowser / curlWindows Firewallhttpd.exe เท่านั้นLocalSubnet เท่านั้นhttpd.exe0.0.0.0:8080 · 0.0.0.0:8443phpinfo เฉพาะ 127.0.0.1mariadbd.exe127.0.0.1:3307 เท่านั้นroot ไม่มีรหัสผ่านไม่มีทางเข้า ไม่มีกฎ ไม่ได้ฟังที่ LANลำดับ: deploy -Lan -ResetConfig แล้ว firewall-lan.ps1 (admin) แล้วสตาร์ต
สามชั้นที่กัน MariaDB ไม่ให้ออก LAN: ฟังที่ 127.0.0.1, ไม่มีกฎ firewall ของ mariadbd และกฎของ Apache ผูกกับ httpd.exe กับพอร์ตที่อ่านจาก Listen เท่านั้น

ขั้นตอนมีห้าขั้น ทำต่อจากที่ APM หยุดสนิทแล้ว (ขั้นที่ 29)

ขั้นที่ 30: deploy ใหม่ด้วย -Lan และ -ResetConfig

ที่ C:\apm-build ใน pwsh แบบ user ปกติ

.\deploy.cmd -Lan -ResetConfig

ต้องมี -ResetConfig ด้วยเมื่อคุณ deploy ไปแล้วก่อนหน้านี้ เพราะ httpd.conf และ apm-ssl.conf เดิมถูก [KEEP] ไว้และยังเขียน Listen 127.0.0.1:... อยู่ ถ้าลืม สคริปต์เตือนด้วย [WARN] httpd.conf was kept and still binds 127.0.0.1 - add -ResetConfig to switch it to LAN ส่วนครั้งแรกที่ยังไม่มี config ใส่ -Lan อย่างเดียวพอ ตัวเลือกพอร์ตใช้ร่วมกันได้ ตอนที่ผมทดสอบ ผมใช้ deploy.cmd -HttpPort 80 -HttpsPort 443 -DbPort 3306 -Lan -ResetConfig (พอร์ตมาตรฐาน เพราะเครื่องทดสอบไม่มี XAMPP) ใครยังมี XAMPP รันอยู่ ให้ใช้พอร์ตค่าเริ่มต้นของซีรีส์นี้ตามที่เขียนด้านบน

ข้อเตือน: -ResetConfig ทับ config ที่คุณแก้ไว้ทั้งสี่ไฟล์ ตามที่เตือนในหัวข้อ deploy ซ้ำ

ขั้นที่ 31: อ่านผลว่า Listen เปลี่ยนแล้ว

ผลที่ต่างจากการ deploy แบบ local มีสามจุด ผมแสดงรูปแบบที่สคริปต์พิมพ์ (ชื่อเครื่องกับ IP เป็นตัวอย่าง)

[OK] cert C:\APM\ssl/localhost.crt  SAN=DNS:localhost,IP:127.0.0.1,IP:::1,DNS:pc-name,IP:192.168.10.x
[OK] C:\APM\data\mysql\my.ini (synced)
Syntax OK
[OK] APM deployed to C:\APM  (http :8080, https :8443, db 127.0.0.1:3307) - not started
     httpd.conf Listen: 0.0.0.0:8080
     LAN: http://192.168.10.x:8080/  https://192.168.10.x:8443/
     Firewall: run scripts\firewall-lan.ps1 once as Administrator

จุดแรก SAN ของใบรับรองมีชื่อเครื่องและ IP ของ LAN เพิ่ม จุดที่สอง Listen เป็น 0.0.0.0:8080 จุดที่สามมีบรรทัด LAN: หนึ่งบรรทัดต่อหนึ่ง IP ที่เครื่องมี (ถ้าเครื่องมีทั้งสาย LAN และ Wi-Fi จะเห็นสองบรรทัด) และเตือนเรื่อง firewall ถ้า IP ของคุณเปลี่ยนภายหลัง (DHCP แจกใหม่) ใบรับรองจะไม่ครอบ IP ใหม่ ให้รันขั้นนี้ซ้ำ

ขั้นที่ 32: เปิด firewall ครั้งเดียวด้วย firewall-lan.ps1

Windows Firewall ปิดกั้นขาเข้าเป็นค่าเริ่มต้น -Lan อย่างเดียวเลยยังไม่พอ ต้องเปิดกฎให้ httpd.exe ขั้นนี้ต้องใช้สิทธิ์ผู้ดูแล คลิกขวาที่ pwsh เลือก Run as administrator แล้วรัน

pwsh -NoProfile -File C:\apm-build\scripts\firewall-lan.ps1

ผลที่ควรเห็น (พอร์ตตามที่คุณ deploy)

[OK] firewall rule 'APM Apache (LAN)': TCP 8080,8443 from LocalSubnet -> C:\APM\apache\bin\httpd.exe

ถ้าไม่ได้รันแบบ admin จะหยุดด้วย [ERR] run as Administrator กฎที่สร้างมีขอบเขตแคบโดยตั้งใจ

  • ขาเข้า (Inbound) TCP เท่านั้น
  • ผูกกับโปรแกรม C:\APM\apache\bin\httpd.exe ตัวเดียว ไม่ใช่เปิดพอร์ตลอย
  • รับเฉพาะ LocalSubnet คือเครื่องในซับเน็ตเดียวกัน ไม่ใช่ทั้งอินเทอร์เน็ต
  • ใช้กับทุกโปรไฟล์เครือข่าย (Any) เพราะ Wi-Fi ที่บ้านบางทีถูก Windows จัดเป็น Public
  • พอร์ตอ่านจากบรรทัด Listen ใน httpd.conf กับ apm-ssl.conf จริง ไม่ได้เดาจากค่าเริ่มต้น ถ้าไม่เจอพอร์ตเลยจะหยุดด้วย [ERR] no Listen ports found in ...

รันซ้ำได้ มันลบกฎเดิมชื่อเดียวกันก่อนแล้วสร้างใหม่ เลยไม่เกิดกฎซ้ำซ้อน

ขั้นที่ 33: สตาร์ตแล้วทดสอบจากอีกเครื่อง

สตาร์ต MariaDB กับ Apache ตามขั้นที่ 15 ถึง 18 บนเครื่อง APM แล้วที่เครื่องอื่นในซับเน็ตเดียวกัน (ตัวอย่างใช้ IP 192.168.10.x แทน IP จริงของเครื่อง APM) ลองสองคำสั่งนี้

curl.exe -s -o NUL -w "%{http_code}`n" http://192.168.10.x:8080/
curl.exe -s -o NUL -w "%{http_code}`n" http://192.168.10.x:8080/server-status

คำสั่งแรกควรได้ 200 คำสั่งที่สองควรได้ 403 เพราะ /server-status มี Require local ใน httpd.conf ผลที่ผมวัดจริงจากอีกเครื่องใน LAN หลัง deploy.cmd -Lan -ResetConfig และเปิดกฎ firewall (ใช้พอร์ต 80, 443 และ 3306 ตามที่อธิบายในขั้นที่ 30)

  • http ได้ 200 และ https ได้ 200
  • ?info=1 ได้หน้าปกติ ไม่ใช่ phpinfo (phpinfo ดูได้เฉพาะจากเครื่อง APM เอง)
  • /server-status ได้ 403
  • พอร์ต 3306 ของ MariaDB ปิด ต่อไม่ติด

ตามที่ตั้งใจไว้หมด ส่วนการเปิดผ่าน HTTPS จากเครื่องอื่นโดยไม่ขึ้นคำเตือน ต้องนำเข้า apm-ca.crt ในเครื่องนั้นก่อนตามที่เขียนในหัวข้อใบรับรอง และต้องเปิดด้วยชื่อหรือ IP ที่อยู่ใน SAN เท่านั้น

ขั้นที่ 34: เลิกเปิด LAN

หยุด APM (ขั้นที่ 26 ถึง 29) แล้วทำสองอย่าง อย่างแรกลบกฎ firewall ใน pwsh แบบ admin

pwsh -NoProfile -File C:\apm-build\scripts\firewall-lan.ps1 -Remove

ผลที่ควรเห็นคือ [OK] removed firewall rule 'APM Apache (LAN)' อย่างที่สองคือ render config กลับเป็น local ที่ C:\apm-build แบบ user ปกติ

.\deploy.cmd -ResetConfig

เมื่อไม่มี -Lan ค่า $Bind กลับเป็น 127.0.0.1 ตรวจจากบรรทัดสุดท้ายว่า httpd.conf Listen: 127.0.0.1:8080 ขั้นนี้ผมอ่านจากโค้ด ยังไม่ได้ลองย้อนกลับจริง

ทำไม MariaDB ไม่เคยเปิดออก LAN

ผมตั้งใจให้ -Lan แตะเฉพาะ Apache เพราะ root ของ MariaDB ใน APM ไม่มีรหัสผ่าน (เหมือน XAMPP) ซึ่งปลอดภัยได้เพราะมันฟังแค่ 127.0.0.1 ถ้าฐานข้อมูลออก LAN ใครในวงเดียวกันก็ต่อเข้ามาเป็น root ได้ทันที สามชั้นที่กันไว้

  1. my.ini ตายตัว บรรทัด bind-address = 127.0.0.1 ใน template ไม่ได้ใช้ตัวแปร {{BIND}} เลยไม่ว่าจะสั่ง -Lan หรือไม่ ค่านี้ไม่เปลี่ยน (และ deploy พิมพ์ db 127.0.0.1:3307 ในบรรทัดสรุปทุกครั้ง)
  2. กฎ firewall ผูกกับ httpd.exe ตัวเดียว ต่อให้มีใครเปลี่ยน bind ของ MariaDB ไปเอง Windows Firewall ก็ไม่มีกฎให้ mariadbd.exe รับขาเข้า
  3. พอร์ตในกฎมาจากบรรทัด Listen ของ Apache เท่านั้น ไม่มีทางที่ 3307 จะหลุดเข้ากฎโดยบังเอิญ

ถ้าวันหนึ่งคุณต้องการเข้าฐานข้อมูลจากเครื่องอื่นจริงๆ ให้ทำ SSH tunnel หรือตั้ง user เฉพาะพร้อมรหัสผ่านและ bind เอง อย่าเปิดผ่าน -Lan

บั๊กที่เจอหลังซีรีส์ออก และ kit รุ่นใหม่แก้อะไร

หลังจากตอนนี้ออกไปครั้งแรก ผมเอาชุดที่ build เสร็จไปทดสอบต่อ ทั้งแตกไว้ในพาธแปลกๆ และรัน APM สองโฟลเดอร์พร้อมกัน เจอบั๊กเพิ่มห้าตัวที่ทำให้ผมแก้สคริปต์กับ template (บั๊ก PidFile ในหัวข้อสตาร์ตครั้งแรกเป็นอีกตัวที่แก้ในรอบเดียวกัน) แต่ละตัวผมเล่าเป็นอาการ ข้อความที่เห็น สาเหตุ และทางแก้ ตรงที่ผมไม่มีข้อความ error ของจริงเก็บไว้ ผมบอกว่าให้ตรวจอะไรแทนการเดาข้อความ

ใครโหลด kit ก่อนบ่ายวันที่ 22 ตุลาคม 2026

kit รุ่นที่ปล่อยตอนแรกมีบั๊กทั้งห้าตัวด้านล่าง ให้โหลดใหม่จาก สารบัญ (ไฟล์ใหม่ขนาด 48,142 ไบต์ sha256 ขึ้นต้น 24BA604D) แตกทับ C:\apm-build แล้วรัน .\deploy.cmd -ResetConfig ถ้าคุณยังไม่เคยแก้ config ใน C:\APM ทางนี้จบในคำสั่งเดียว ถ้าแก้ไว้แล้วและไม่อยากให้ทับ ให้รัน .\deploy.cmd เฉยๆ (binary ใหม่ รวมถึง plugin ของ MariaDB จะมาครบ) แล้วคัดลอกบรรทัดที่เปลี่ยนเข้า config ด้วยมือ สามจุดคือ PidFile กับ FcgidWrapper ใน httpd.conf และบล็อก opcache.cache_id ใน php.ini (ตัวอย่างอยู่ในหัวข้อ templates ด้านบน ค่า cache_id ใส่ชื่อที่ไม่ซ้ำกับโฟลเดอร์อื่นก็พอ เช่น apmmain)

บั๊กที่ 1: plugin ของ MariaDB หายหลัง deploy

อาการ: deploy ผ่านทุกบรรทัด แต่ DLL ของ plugin MariaDB หายจาก C:\APM ฟีเจอร์ที่ต้องใช้ plugin ตัวนั้น (เช่น engine ARCHIVE หรือการล็อกอินแบบ ed25519) เลยใช้ไม่ได้ตามไปด้วย ผมไม่ได้เก็บข้อความ error ของ MariaDB ไว้ ให้ตรวจที่ไฟล์แทน

Get-ChildItem C:\APM\mariadb\lib\plugin -Filter *.dll -ErrorAction SilentlyContinue | Select-Object -First 5 Name

ถ้าไม่มีอะไรพิมพ์ออกมา โฟลเดอร์หายหรือว่างเปล่า เทียบกับต้นทางที่ C:\apm-build\stage\mariadb\lib\plugin ซึ่งต้องมี DLL อย่าง ha_archive และ auth_ed25519 อยู่

สาเหตุ: รายการข้ามโฟลเดอร์ของ robocopy สำหรับ MariaDB มีคำว่า lib ใน /XD ผมใส่ไว้เพื่อตัดโฟลเดอร์ lib ระดับบนที่มีแต่ไฟล์ .lib สำหรับนักพัฒนา แต่ /XD lib ตัดโฟลเดอร์ ชื่อ lib ทุกตัวในทุกระดับ รวม lib\plugin ที่เก็บ DLL ของ plugin ซึ่งต้องใช้ตอนรัน

ทางแก้: เอา lib ออกจาก /XD ของ MariaDB ส่วนไฟล์ *.lib ถูกตัดด้วย /XF อยู่แล้ว

# เดิม
mariadb = @('/XD', 'include', 'lib', 'symbols', 'sql-bench', '/XF', '*.pdb', '*.lib', '*.exp', 'my.ini')
# ใหม่ (พร้อมคอมเมนต์ในสคริปต์)
mariadb = @('/XD', 'include', 'symbols', 'sql-bench', '/XF', '*.pdb', '*.lib', '*.exp', 'my.ini')

คนที่ deploy ไปแล้วแค่โหลด kit ใหม่แล้วรัน .\deploy.cmd อีกรอบ ไม่ต้อง -ResetConfig เพราะ robocopy จะคัดลอกโฟลเดอร์ plugin ที่ขาดมาให้ โดยไม่แตะ my.ini และข้อมูล

บั๊กที่ 2: เปลี่ยนพอร์ตฐานข้อมูลด้วย -ResetConfig แล้วไม่มีผล

อาการ: deploy ใหม่ด้วย .\deploy.cmd -DbPort 3306 -ResetConfig แล้วสตาร์ต MariaDB ตามขั้นที่ 15 พอร์ตยังเป็น 3307 แต่หน้าแรกของ APM ไปต่อพอร์ต 3306 (เพราะ php.ini ถูกเรนเดอร์ใหม่ มี mysqli.default_port = 3306) หน้าเลยขึ้น not reachable ตรวจได้ด้วย

Select-String -Path C:\APM\mariadb\my.ini, C:\APM\data\mysql\my.ini -Pattern '^port'

ถ้ารุ่นเก่า สองไฟล์จะได้เลขพอร์ตไม่ตรงกัน (ไฟล์แรก 3306 ไฟล์ที่สอง 3307) ผลนี้ผมอนุมานจากโค้ด ไม่ได้เก็บผลจริงตอนเจอ

สาเหตุ: server ไม่ได้อ่าน C:\APM\mariadb\my.ini แต่อ่านสำเนาที่ mariadb-install-db วางไว้ใน data\mysql\my.ini ตอนสร้าง datadir ครั้งแรก และรุ่นเก่าไม่เคยแตะไฟล์นั้นอีก -ResetConfig เรนเดอร์ไฟล์ในโฟลเดอร์ mariadb ใหม่ซึ่ง server ไม่อ่าน

ทางแก้: เพิ่มกิ่ง elseif ($ResetConfig) ท้ายช่วง datadir ให้คัดลอก mariadb\my.ini ทับ data\mysql\my.ini และพิมพ์ [OK] C:\APM\data\mysql\my.ini (synced) (โค้ดเต็มอยู่ในสคริปต์ที่วางไว้ด้านบน) ข้อความเก่าในตอนนี้ที่เคยบอกให้แก้ data\mysql\my.ini ด้วยมือ เลยเลิกใช้แล้ว

บั๊กที่ 3: รัน APM สองโฟลเดอร์พร้อมกัน ทุกหน้า PHP ขึ้น 500

อาการ: ผมแตกชุดที่แพ็กเสร็จไปทดสอบอีกโฟลเดอร์ ในขณะที่ C:\APM ยังรันอยู่ ปรากฏว่าทุกหน้า PHP ตอบ 500 ใน error.log ของ Apache (logs\apache\error.log) มีข้อความ

End of script output before headers

พอผมสั่ง php-cgi.exe -v ของโฟลเดอร์ใหม่ตรงๆ ก็เจอข้อความสาเหตุจริง

Opcode handlers are unusable due to ASLR

สาเหตุ: บน Windows OPcache แชร์หน่วยความจำกันตามชื่อ เมื่อ PHP build เดียวกัน รันโดย user เดียวกัน สองโฟลเดอร์เลยไปแย่งหน่วยความจำก้อนเดียวกัน ที่อยู่ที่ก้อนนั้นถูกจัดวางไว้ไม่ตรงกับ process ที่สอง (ASLR สุ่มที่อยู่ต่างกัน) ตัวที่มาทีหลังเลยใช้ opcode ที่เก็บไว้ไม่ได้และตายก่อนส่ง header ถ้าผมไม่ลองรันคู่กัน คงไม่เจอบั๊กนี้เลย

ทางแก้: ตั้งชื่อแคชแยกตามโฟลเดอร์ติดตั้ง และเปิดแคชแบบไฟล์เป็นตัวสำรอง สามบรรทัดใน php.ini ที่อยู่ในหัวข้อ templates ด้านบน (opcache.cache_id, opcache.file_cache, opcache.file_cache_fallback = 1) ส่วนค่า {{CACHE_ID}} ผมให้ 90-deploy.ps1 คำนวณจากพาธ เลยไม่ซ้ำกันระหว่างสองโฟลเดอร์โดยไม่ต้องคิดชื่อเอง และ deploy สร้าง tmp\opcache ไว้ให้

ข้อสังเกต: สองโฟลเดอร์ที่รันพร้อมกันยังต้องใช้พอร์ต Apache และ MariaDB คนละเลข (-HttpPort, -HttpsPort, -DbPort) เพราะ cache_id แก้เฉพาะเรื่องแคช

บั๊กที่ 4: พาธติดตั้งมีช่องว่าง Apache ขึ้น Wrapper cannot be accessed

อาการ: ผมแตกชุดไว้ในโฟลเดอร์ที่ชื่อมีช่องว่าง (ตัวอย่างคือ D:\APM test) แล้ว deploy ลงที่นั่น Apache ฟ้องใน error.log ด้วยข้อความที่มีคำว่า

Wrapper D:/APM cannot be accessed

สังเกตว่าพาธในข้อความถูกตัดที่ช่องว่าง เหลือแค่ D:/APM

สาเหตุ: mod_fcgid แยกบรรทัดคำสั่งของ FcgidWrapper ด้วยช่องว่าง (ตามคอมเมนต์ในไฟล์คือ apr_tokenize_to_argv) เครื่องหมายคำพูดครอบนอกของ directive ใน httpd.conf ถูก Apache ถอดออกก่อนส่งให้ module เลยเหลือพาธเปล่าๆ ที่มีช่องว่าง และถูกตัดเป็นสองส่วน

ทางแก้: ใส่เครื่องหมายคำพูดซ้อนข้างใน (เลี่ยงด้วย \") เพื่อให้ module เห็นเครื่องหมายคำพูดครอบพาธอีกชั้น

FcgidWrapper "\"${APM}/php/php-cgi.exe\"" .php

พาธมาตรฐาน C:\APM ไม่มีช่องว่าง ส่วนใหญ่เลยไม่เจอบั๊กนี้ มันโผล่เมื่อใช้ -Target "D:\APM test" หรือแตกไว้ใต้โฟลเดอร์ที่ชื่อมีช่องว่าง

บั๊กที่ 5: พาธติดตั้งมีวงเล็บ ออกใบรับรองไม่ได้ Undefined constant OPENSSL_KEYTYPE_EC

อาการ: deploy ลงโฟลเดอร์ที่ชื่อมีวงเล็บ เช่น D:\APM (copy) แล้วช่วงออกใบรับรองล้ม ข้อความมาจาก PHP ของ mkcert.php ไม่ใช่จากสคริปต์ deploy

Undefined constant OPENSSL_KEYTYPE_EC

ตามด้วย [ERR] certificate generation failed ของ 90-deploy.ps1

สาเหตุ: สคริปต์ส่งพาธ extension ให้ PHP ด้วย php -d extension_dir=<พาธ> แต่ค่าของ -d ถูกอ่านด้วยไวยากรณ์ INI ซึ่งวงเล็บ ( ) มีความหมายพิเศษ ค่าเลยเพี้ยน PHP หาโฟลเดอร์ extension ไม่เจอ ไม่โหลด openssl และเมื่อ mkcert.php ไปอ้างค่าคงที่ OPENSSL_KEYTYPE_EC ซึ่งมีเฉพาะเมื่อ openssl โหลดแล้ว เลยฟ้องว่าไม่มีค่าคงที่นี้ ข้อความนี้ไม่ได้บอกต้นเหตุตรงๆ ว่า openssl โหลดไม่ขึ้น

ทางแก้: สองจุด จุดแรกใน 90-deploy.ps1 ครอบค่าด้วยเครื่องหมายคำพูดอีกชั้น

& "$Target\php\php.exe" -n -d "extension_dir=`"$Target\php\ext`"" -d extension=openssl `
    "$Root\tools\mkcert.php" "$Target\ssl" @certHosts

จุดที่สองใน mkcert.php เช็กก่อนเริ่มว่า openssl โหลดจริง ถ้าไม่ได้ให้บอกต้นเหตุตรงๆ

if (!extension_loaded('openssl')) {
    fwrite(STDERR, "[ERR] PHP openssl extension not loaded (check -d extension_dir=\"...\" - quote it, folder names may contain ( ) or spaces)\n");
    exit(1);
}

บั๊กที่ 4 และ 5 มาจากรากเดียวกัน คือเดิมผมทดสอบแต่ใน C:\APM ที่พาธสะอาดทุกอย่าง ตอนนี้ถ้าคุณ deploy ไปที่อื่น พาธแปลกๆ ใช้ได้แล้ว

อัปเดตเมื่อมี security release

นี่คือเหตุผลที่ผมทำทั้งซีรีส์ ทุกเวอร์ชันและ hash อยู่ใน versions.json ไฟล์เดียว เมื่อมีข่าว patch ผมแก้ไฟล์นั้น ดึงเฉพาะส่วนที่เปลี่ยน build ใหม่เฉพาะส่วนนั้น แล้ว deploy.cmd อีกรอบ

ขอบเขตของสิ่งที่ผมทดสอบ

ขั้นตอนข้างล่างเรียบเรียงจากสคริปต์ที่ผมอ่านและจากการรัน build ซ้ำทั้งชุดโดยไม่เปลี่ยนเวอร์ชัน (ครบวงจรใช้เวลา 10 นาที: fetch 0.0, vcpkg 0.1, apache 1.6, mariadb 0.3, imagemagick 3.3, php 5.0) ผมยังไม่ได้ผ่านการเปลี่ยนเวอร์ชันจริงครบวงจร เพราะยังไม่มี release ถัดไปให้เปลี่ยน ส่วนที่เปลี่ยนใน kit รุ่นใหม่ (`-Only` แยกจุลภาคเอง และ PECL ที่คัดลอกใหม่ทุกรอบ) ผมอ่านจากโค้ดเช่นกัน เลขเวอร์ชันในตัวอย่างด้านล่างเลยเป็นตัวเลขสมมติ ทุกครั้งที่ผมบอกว่าสคริปต์ทำอะไร นั่นมาจากการอ่านโค้ด

ลูปอัปเดต: แก้ไฟล์เดียว แล้วรันซ้ำเฉพาะส่วนที่เปลี่ยน1 แก้ versions.jsonversion url sha256ของส่วนที่ออก patch2 fetch00-fetch.ps1 -Onlyตรวจ sha256 แล้วแตกไฟล์3 build ส่วนนั้น30-php.ps1 -Step allphp 5 นาที (รอบ rebuild)4 หยุด APMhttpd · php-cgi · mariadbddeploy ล้มถ้ายังรันอยู่5 deploy.cmd.\deploy.cmdทับ binary เก็บ config6 สตาร์ตและตรวจphp -v · curlหน้าแรกขึ้นเลขใหม่เปลี่ยนเลข PECL: ลบบรรทัดของรายการใน versions.lock.json (php-pecl คัดลอกใหม่เอง)เวลาที่วัดได้: php 5.0 นาที · apache 1.6 นาที · ทั้งชุดซ้ำ 10 นาที (2026-10-22)
ทั้งหกขั้นไม่มีขั้นไหนต้องแก้โค้ดสคริปต์ ทุกอย่างขับด้วยไฟล์ versions.json ไฟล์เดียว

เลือกให้ถูกว่าต้อง build ส่วนไหน

ข่าว security มักระบุว่าช่องโหว่อยู่ในโปรแกรมไหน ใช้ตารางนี้ตัดสินใจว่าต้องแก้อะไรและรันสคริปต์ไหน (จาก HOWTO ส่วน "อัปเดตเมื่อมี security patch" ที่ผมสรุปไว้ พร้อมจุดที่ผมเจอเพิ่มจากการอ่านสคริปต์)

ตัวที่ออก patch แก้ตรงไหน รันอะไร
PHP sources.php (version, url, sha256) 00-fetch.ps1 -Only php แล้ว 30-php.ps1 -Step all
Apache httpd / APR sources.httpd หรือ sources.apr sources.apr-util 00-fetch.ps1 -Only httpd แล้ว 20-apache.ps1
MariaDB sources.mariadb 00-fetch.ps1 -Only mariadb แล้ว 40-mariadb.ps1
redis, igbinary, imagick (PECL) sources.pecl-* และลบบรรทัดของรายการนั้นใน versions.lock.json (ไม่ต้องลบ build\php-pecl แล้ว สคริปต์คัดลอกใหม่ทุกรอบ) 00-fetch.ps1 -Only pecl-redis (ตามชื่อ) แล้ว 30-php.ps1 -Step build
OpenSSL ฝั่ง Apache ports-overlay\openssl\vcpkg.json (version) และ portfile.cmake (SHA512) 10-deps-vcpkg.ps1 แล้ว 20-apache.ps1
lib อื่นของ Apache git -C toolchain\vcpkg pull แล้วแก้ builtin-baseline ใน deps\apache\vcpkg.json เป็น commit ใหม่ 10-deps-vcpkg.ps1 แล้ว 20-apache.ps1
ImageMagick sources.imagemagick.version (git tag) 25-imagemagick.ps1 แล้ว 30-php.ps1 -Step build
DLL ของ deps ใน PHP ไม่ต้องแก้ไฟล์ 30-php.ps1 -Step all (phpsdk_deps -u ดึงรุ่นล่าสุดให้เอง)
ย้ายไป PHP 8.5 sources.php และ php_deps_series เป็น "8.5" ทำเมื่อเครื่อง production ย้ายเท่านั้น

ข้อสังเกตที่สำคัญ OpenSSL ที่ ฝั่ง PHP มาจากชุด deps ของ PHP team (รอบนี้เป็น 3.0.22) ไม่ใช่ตัวเดียวกับของ Apache (3.5.9) ถ้า OpenSSL ออก patch ต้องดูว่าอยากแก้ฝั่งไหน ฝั่ง Apache แก้ที่ overlay เอง ส่วนฝั่ง PHP รอให้ PHP team ปล่อยชุด deps ใหม่ แล้ว 30-php.ps1 -Step all จะดึงให้

ด้านล่างผมใช้ PHP เป็นตัวอย่างหลัก ส่วน Apache และ MariaDB ขั้นตอนเหมือนกันทุกประการ ต่างกันแค่ชื่อรายการและสคริปต์

ขั้นที่ 35: เปิด terminal และหยุด APM

เปิด pwsh ธรรมดา แล้วหยุดทุกอย่างตามขั้นที่ 26 ถึง 29 ของหัวข้อสตาร์ตครั้งแรก ถ้า APM ยังไม่ได้รัน ขั้นนี้ข้ามได้

ขั้นที่ 36: สำรองเวอร์ชันที่ใช้อยู่ (ทางเลือก แต่ผมแนะนำ)

deploy.cmd ทับไบนารีเดิมด้วย /MIR ทำให้ไม่มีเวอร์ชันเก่าเหลือให้ย้อน ผมเลยแนะนำให้คัดลอกส่วนที่จะเปลี่ยนเก็บไว้ก่อน เช่นกรณี PHP

robocopy C:\APM\php C:\APM-prev\php /MIR /NFL /NDL /NJH /NJS /NP

คำสั่งนี้ไม่พิมพ์อะไร และ robocopy คืนรหัสออก 1 เมื่อคัดลอกสำเร็จ (ไม่ใช่ข้อผิดพลาด) ตรวจว่าโฟลเดอร์ปลายทางมีไฟล์

Test-Path C:\APM-prev\php\php-cgi.exe
True

ถ้าส่วนที่จะอัปเดตคือ MariaDB ให้สำรองฐานข้อมูลด้วยการ dump ก่อนด้วย (ต้องสตาร์ต MariaDB ก่อน แล้วปิดตามเดิมหลังจบ ผมไม่ได้ลองคำสั่งนี้ในรอบทดสอบ)

C:\APM\mariadb\bin\mariadb-dump.exe -uroot --port=3307 --protocol=tcp --all-databases --result-file=C:\APM-prev\all.sql

ขั้นที่ 37: ไปที่โฟลเดอร์ build และสำรอง versions.json

cd C:\apm-build
Copy-Item versions.json versions.json.bak

ไม่มีผลพิมพ์ ไฟล์ .bak ไว้เทียบหรือย้อนถ้าแก้ผิด

ขั้นที่ 38: หาเลขเวอร์ชันใหม่และ sha256 ของแต่ละตัว

ที่มาของแต่ละค่า (ต้องได้ครบสามอย่างต่อหนึ่งรายการ: version, url, sha256)

  • PHP: เปิด https://www.php.net/releases/index.php?json&version=8.4&max=1 ใน browser ดูรายการไฟล์ของ release ล่าสุด หา php-<เวอร์ชัน>.tar.xz แล้วคัดลอกค่า sha256 ของไฟล์นั้น url คือ https://www.php.net/distributions/php-<เวอร์ชัน>.tar.xz
  • httpd และ APR: ไฟล์ .sha256 ที่อยู่ข้างไฟล์ tarball บน downloads.apache.org ดึงค่าด้วย pwsh ได้ (สมมติ httpd 2.4.70) เอาเฉพาะช่องแรกซึ่งเป็น hash
((curl.exe -fsSL https://downloads.apache.org/httpd/httpd-2.4.70.tar.bz2.sha256) -split '\s+')[0]

ผลที่ควรเห็นคือสตริงฐานสิบหก 64 ตัวบรรทัดเดียว (ถ้า curl ตอบ 404 แปลว่ารุ่นนั้นถูกย้ายไป archive.apache.org หรือพิมพ์เลขรุ่นผิด)

  • MariaDB: ดูที่ https://downloads.mariadb.org/rest-api/mariadb/11.8/latest/ ซึ่งบอก hash ของไฟล์ source แต่ละอันในรุ่นล่าสุด url มีรูปแบบ https://archive.mariadb.org/mariadb-<เวอร์ชัน>/source/mariadb-<เวอร์ชัน>.tar.gz
  • ถ้าหา hash บนเว็บต้นทางไม่เจอ: ดาวน์โหลดไฟล์ด้วยมือ แล้วเทียบกับหน้าประกาศ release ด้วย Get-FileHash <ไฟล์> -Algorithm SHA256 อย่าใช้ค่าจากเว็บของบุคคลที่สาม

ขั้นที่ 39: แก้ versions.json

เปิดด้วย Notepad (หรือ editor ที่คุณใช้)

notepad C:\apm-build\versions.json

หาบรรทัดของ php ใต้ sources เปลี่ยนสามค่าในบรรทัดเดียวกัน ตัวอย่างสมมติให้ PHP ออกรุ่น 8.4.28 (หน้าตาก่อนและหลัง ผมย่อ hash ให้สั้นลงในบทความ ของจริงต้องเต็ม 64 ตัว)

ก่อนแก้

"php": { "version": "8.4.27", "url": "https://www.php.net/distributions/php-8.4.27.tar.xz", "sha256": "dcf93923b0e3...d3f0" },

หลังแก้

"php": { "version": "8.4.28", "url": "https://www.php.net/distributions/php-8.4.28.tar.xz", "sha256": "<sha256 ของ php-8.4.28.tar.xz จาก php.net>" },

เปลี่ยนให้ครบทั้งสามช่อง อย่าเปลี่ยนแค่ version เพราะ url กับ sha256 ไม่ตามมาเอง และห้ามเว้น sha256 เป็น null สำหรับรายการที่ต้นทางประกาศ hash ไว้ (ผมตั้งใจให้ PHP, httpd, APR, MariaDB มี hash ตรึงไว้เสมอ)

ขั้นที่ 40: ตรวจว่า JSON ยังถูกรูปแบบ

Get-Content C:\apm-build\versions.json -Raw | ConvertFrom-Json | Out-Null

ผลที่ควรเห็นคือ ไม่มีอะไรพิมพ์ออกมา ถ้ามีตัวหนังสือสีแดงขึ้น แปลว่าไฟล์เสียรูป (ส่วนมากลืมเครื่องหมายคอมมา หรือมีอัญประกาศขาด) กลับไปแก้ที่ขั้นที่ 39 ถ้าแก้ไม่หาย คัดลอก versions.json.bak กลับมาแล้วเริ่มใหม่

ขั้นที่ 41: จัดการรายการที่ใช้ lock file (เฉพาะกรณีที่แก้เลขของรายการที่ไม่มี sha256)

รายการอย่าง pecl-redis, pecl-igbinary, pecl-imagick, mod_fcgid และ toolchain ที่ใน versions.json เป็น "sha256": null ใช้ระบบ trust-on-first-use คือ 00-fetch.ps1 บันทึก hash ครั้งแรกใน versions.lock.json และครั้งต่อไปเทียบกับค่านั้น เมื่อคุณเปลี่ยนเวอร์ชันของรายการเหล่านี้ hash ใหม่จะไม่ตรงกับ hash เก่าใน lock และสคริปต์จะหยุดด้วย [ERR] sha256 mismatch ดังนั้นให้ลบบรรทัดของรายการนั้นออกจาก versions.lock.json ก่อน เช่น ลบบรรทัด "pecl-redis": "..." ออก (ระวังเครื่องหมายคอมมาท้ายบรรทัดสุดท้ายให้ JSON ยังถูกต้อง)

ตอนที่ 6 เคยเตือนให้ลบโฟลเดอร์ build\php-pecl\<ชื่อ> ด้วยมือ เพราะสคริปต์รุ่นแรกคัดลอก PECL ไปไว้ครั้งเดียว ชื่อโฟลเดอร์ไม่มีเลขเวอร์ชันติด พอแก้เวอร์ชันแล้ว build ก็ยังใช้ source เก่าเงียบๆ kit รุ่นใหม่ไม่ต้องทำขั้นนั้นแล้ว 30-php.ps1 ลบ build\php-pecl\<ชื่อ> แล้วคัดลอกจาก src\pecl-<ชื่อ>-<เวอร์ชัน> ใหม่ทุกครั้งที่รัน และถ้า source ของเวอร์ชันที่ระบุยังไม่ถูก fetch จะหยุดด้วย [ERR] <พาธ> missing - run 00-fetch.ps1 ให้ทำขั้นที่ 42 ก่อน ส่วนบรรทัดใน versions.lock.json ยังต้องลบเองเหมือนเดิม เพราะ 00-fetch.ps1 ยังเทียบ hash กับ lock อยู่ ข้ามขั้นนี้ได้เมื่อคุณอัปเดตเฉพาะ PHP, httpd, APR หรือ MariaDB เพราะ 4 ตัวนั้นมี hash ใน versions.json อยู่แล้ว ผมสรุปเรื่อง lock และการคัดลอกใหม่จากการอ่าน 00-fetch.ps1 กับ 30-php.ps1 ยังไม่ได้ทดสอบกับการเปลี่ยนเวอร์ชัน PECL จริง

ขั้นที่ 42: ดึง source ใหม่เฉพาะตัวที่เปลี่ยน

pwsh -NoProfile -File scripts\00-fetch.ps1 -Only php

ผลที่ควรเห็น (เลขเวอร์ชันตามที่คุณแก้ ตัวอย่างสมมติ)

[GET] php 8.4.28
[OK] php -> C:\apm-build\src\php-8.4.28

ไฟล์ที่ดาวน์โหลดเก็บใน downloads\ และแตกลง src\php-8.4.28 โดยโฟลเดอร์ source เก่า (src\php-8.4.27) ยังอยู่ ไม่ถูกลบ ถ้า sha256 ไม่ตรงกับที่คุณใส่ใน versions.json สคริปต์ throw ทันที พร้อมแสดงค่าที่ได้และค่าที่คาดไว้ ห้ามบังคับผ่าน ให้เช็กว่าคัดลอก hash ถูกตัวและถูกไฟล์ก่อน ถ้าต้องดาวน์โหลดและแตกใหม่จริงๆ ใส่ -Force ท้ายคำสั่ง

ถ้าอัปเดตหลายตัวพร้อมกัน ใส่รายชื่อคั่นด้วยจุลภาคได้เลย

pwsh -NoProfile -File scripts\00-fetch.ps1 -Only php,httpd

ผลที่ควรเห็นคือบล็อกของแต่ละตัวตามลำดับ (เลขเวอร์ชันสมมติเหมือนเดิม)

[GET] php 8.4.28
[OK] php -> C:\apm-build\src\php-8.4.28
[GET] httpd 2.4.70
[OK] httpd -> C:\apm-build\src\httpd-2.4.70

ใช้ได้เพราะ pwsh -File ส่ง php,httpd เข้าไปเป็นข้อความก้อนเดียว และ 00-fetch.ps1 รุ่นใหม่แยกจุลภาคเองด้วยบรรทัดนี้ ซึ่งอยู่ใต้บรรทัด param (รุ่นแรกไม่มีบรรทัดนี้ เลยโหลดอะไรไม่ได้และไม่ขึ้น error ดูเรื่องเดิมใน ตอนที่ 2 ใครถือ kit เก่าอยู่ให้โหลดรุ่นใหม่) บรรทัดที่เพิ่มคือ

$Only = @($Only | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ })

ขั้นที่ 43: build เฉพาะส่วนที่เปลี่ยน

สำหรับ PHP

pwsh -NoProfile -File scripts\30-php.ps1 -Step all

เวลาที่วัดได้ของการ build ซ้ำเฉพาะส่วน: php 5.0 นาที, apache 1.6 นาที (รอบแรกของแต่ละส่วนนานกว่า: php 12, apache 4, mariadb 30 นาที) ดูรายละเอียดการอ่านผลของ PHP ในตอนที่ 6 ส่วน Apache ใช้ 20-apache.ps1 และ MariaDB ใช้ 40-mariadb.ps1 ตามตารางข้างบน

ถ้าไม่แน่ใจว่าต้อง build ส่วนไหน การรัน .\build.cmd ทั้งชุดก็ถูกต้องเสมอ ถ้า cache ยังอยู่ ใช้เวลาประมาณ 10 นาทีตามที่วัดได้ รุ่นใหม่ของ build.cmd มีตัวเลือก -To ควบคู่กับ -From ด้วย เช่น .\build.cmd -From php -To php เพื่อหยุดหลังขั้นนั้น (ขั้นที่มีคือ fetch vcpkg apache mariadb imagemagick php)

ขั้นที่ 44: ตรวจเวอร์ชันใน stage ก่อน deploy

C:\apm-build\stage\php\php.exe -v

บรรทัดแรกต้องเป็นเวอร์ชันใหม่ในรูปแบบเดียวกับตอนที่ 6 (เช่น PHP 8.4.28 (cli) ... สำหรับตัวอย่างสมมติ) ถ้ายังเห็นเลขเก่า แปลว่าสคริปต์ยังอ่าน versions.json เวอร์ชันเก่าหรือ build ไม่ผ่าน กลับไปดูขั้นที่ 39 และ 43 ส่วน Apache ตรวจด้วย C:\apm-build\stage\apache\bin\httpd.exe -V และ MariaDB ตรวจด้วย C:\apm-build\stage\mariadb\bin\mariadbd.exe --version

ขั้นที่ 45: deploy ทับ

ที่ C:\apm-build ยืนยันว่า APM หยุดอยู่ (ขั้นที่ 35) แล้วรัน

.\deploy.cmd

ผลที่ควรเห็นคือบล็อก [OK] สามบรรทัดของ binary ตามด้วย [KEEP] สี่บรรทัดของ config, Syntax OK, บรรทัดสรุป not started และบรรทัด httpd.conf Listen: ตามที่แสดงไว้ในหัวข้อ deploy ซ้ำ

ขั้นที่ 46: สตาร์ตและยืนยันว่าเป็นรุ่นใหม่

สตาร์ตตามขั้นที่ 15 ถึง 18 แล้วตรวจสองจุด จุดแรกคือเวอร์ชันของไบนารีที่ติดตั้ง

C:\APM\php\php.exe -v

จุดที่สองคือเปิด http://127.0.0.1:8080/ ในเบราว์เซอร์ แถว PHP ต้องแสดงเวอร์ชันใหม่ และ curl.exe ตามขั้นที่ 19 กับ 20 ต้องได้ 200 ทั้งคู่ เมื่อผ่านแล้ว คำสั่ง Remove-Item C:\APM-prev -Recurse -Force ลบชุดสำรองได้ (ลบเมื่อมั่นใจแล้วเท่านั้น)

ขั้นที่ 47: ถ้ารุ่นใหม่มีปัญหา ย้อนกลับ

หยุด APM (ขั้นที่ 26 ถึง 29) แล้วคัดลอกชุดสำรองกลับ โดยกันไม่ให้ทับ php.ini

robocopy C:\APM-prev\php C:\APM\php /MIR /XF php.ini /NFL /NDL /NJH /NJS /NP

ไม่มีผลพิมพ์ ตรวจด้วย C:\APM\php\php.exe -v ต้องกลับเป็นเลขเก่า แล้วสตาร์ตใหม่ วิธีย้อนนี้ผมออกแบบเอง ยังไม่ได้ซ้อมจริง

อัปเดตแล้ว ต้องเช็กข่าวด้วย

การ build และ deploy สำเร็จไม่ได้แปลว่าช่องโหว่ถูกปิด ให้เทียบเลขรุ่นกับที่ประกาศในข่าว security ของ upstream เสมอ (รอบนี้คือ PHP 8.4.27 ขึ้นไป) และอย่าให้เว็บที่ต่ออินเทอร์เน็ตตรงๆ รันบน APM ตามการตั้งค่าเริ่มต้น เพราะ APM ถูกออกแบบให้ฟังที่ 127.0.0.1 และเป็นเครื่องพัฒนา ไม่ใช่เซิร์ฟเวอร์ production

ตารางปัญหาที่เจอทั้งซีรีส์

ตารางแรกคือ 17 อาการที่เกี่ยวกับการ build ทั้งชุด (จากบันทึก HOWTO ส่วนปัญหาที่เจอ รวมข้อ Visual Studio ที่เพิ่มหลังปรับ lib.ps1) ผมใส่ไว้ครบแม้บางข้อเป็นเรื่องของตอนอื่น เพื่อให้ค้นที่เดียวได้ คอลัมน์สุดท้ายบอกว่ารายละเอียดอยู่ตอนไหน ทุกข้อที่ระบุว่า "อยู่ในสคริปต์แล้ว" คือผมแก้ในสคริปต์ให้เรียบร้อยแล้ว คุณไม่ต้องทำอะไร

อาการ สาเหตุ วิธีแก้ ตอน
[ERR] Visual Studio 2022 with C++ x64 tools not found lib.ps1 ใช้ vswhere หา VS 2022 ที่มี component VC.Tools.x86.x64 ไม่เจอ (ไม่ได้ลง C++ workload หรือมีแต่ VS รุ่นอื่น) ติดตั้ง VS 2022 หรือ Build Tools พร้อม component นั้นตามตอนที่ 1 รุ่นไหนก็ได้ ติดตั้งที่ไหนก็ได้ 1
'buildconf.bat' is not recognized environment ตั้ง NoDefaultCurrentDirectoryInExePath=1 cmd เลยไม่ค้นในโฟลเดอร์ปัจจุบัน เรียกเป็น .\buildconf.bat / .\configure.bat (อยู่ในสคริปต์แล้ว) 6
configure ไม่รู้จัก --enable-gd ชื่อ flag บน Windows ต่างจาก Linux ใช้ --with-gd, --with-dom และตรวจด้วย -Step help 6
GD ไม่มี WebP/AVIF, PCRE JIT ไม่ทำงาน --disable-all ปิด option ที่ปกติเปิดอยู่ ใส่ --with-libwebp --with-libavif --with-pcre-jit --enable-redis-session 6
แก้ configure แล้วผลไม่เปลี่ยน nmake ใช้ .obj เดิมต่อ ลบ build\php-obj และ stage\php ก่อน build ทุกครั้ง 6
igbinary/igbinary.h: No such file redis include header แบบมี path ของ igbinary นำหน้า --with-extra-includes=build\php-pecl 6
php.exe ไม่ทำงาน (exit 53) DLL ของ deps ไม่อยู่ข้าง exe ไล่หาด้วย dumpbin /dependents แล้วคัดลอก 6
Compatibility with CMake < 3.5 has been removed ไฟล์ CMake ของ APR/httpd เก่า -DCMAKE_POLICY_VERSION_MINIMUM=3.5 3
Unexpected enablement value "N" for mod_lua CMake ของ httpd รับค่าแค่ O/i/I/a/A และค่าเก่าค้างใน cache ไม่ใส่ ENABLE_LUA และใช้ cmake --fresh 3
mod_lua ถูก build ด้วย Lua 5.5 FindLua51 ไปจับ Lua รุ่นใหม่ของ vcpkg เอา lua ออกจาก manifest (เครื่อง production ก็ไม่ใช้ mod_lua) 3
mod_md.so ต้องใช้ libcurl-d.dll imported target ของ vcpkg ไม่มี config RelWithDebInfo เลยไปใช้ Debug -DCMAKE_MAP_IMPORTED_CONFIG_RELWITHDEBINFO=Release;RelWithDebInfo; 3
ไม่มี conf\mime.types cmake --install ของ httpd ไม่ติดตั้ง conf คัดลอกจาก docs\conf ของ source 3
ImageMagick was build with a 64 channel bit mask and that requires a C++ compiler Configure ของ ImageMagick บังคับ channel mask 64 bit บน x64 ซึ่งทำให้ header ใช้ได้กับ C++ เท่านั้น แต่ imagick เขียนด้วย C แก้ Options.cpp ให้ใช้ 32 bit แล้ว build ImageMagick ใหม่ 5
Configure ของ ImageMagick build ไม่ผ่านเพราะหา v145 ไม่เจอ โปรเจกต์ตั้งไว้สำหรับ VS2026 /p:PlatformToolset=v143 และสั่ง Configure ด้วย /VS2022 5
imagick โหลดไม่ขึ้น (Class "Imagick" not found) ไม่มี extension = imagick ใน php.ini เพิ่มใน template แล้ว ถ้า deploy ไปก่อนหน้านี้ให้รัน deploy.cmd -ResetConfig 6 และ 7
Error loading extension section v3_ca ตอนออก cert openssl.cnf ที่ยืมมาจาก deps ไม่มี section ที่ต้องใช้ mkcert.php สร้าง config ของตัวเองทั้งไฟล์ 7
perl ของ Git ทำให้ OpenSSL พัง perl ของ Git เป็น cygwin สร้าง path แบบ Unix ใช้ Strawberry Perl ใน toolchain\ เท่านั้น 2 และ 4

ตารางที่สองคืออาการที่เกิดตอน deploy, สตาร์ต, เปิด LAN และอัปเดต ซึ่งบางข้อเป็นข้อความ error จริงที่ 90-deploy.ps1 พิมพ์

อาการ สาเหตุ วิธีแก้
[ERR] httpd from C:\APM is running - stop APM before deploying (หรือ mariadbd, php-cgi) มี process ของ APM รันอยู่ Windows เขียนทับไฟล์ที่ใช้งานอยู่ไม่ได้ หยุดตามขั้นที่ 26 ถึง 29 แล้ว deploy ใหม่
[ERR] service APM-Apache is running - stop APM before deploying (หรือ APM-MariaDB) มี Windows service ของ APM แบบพร้อมใช้รันอยู่ process ของ service เห็นไม่ได้จาก Get-Process เลยมีข้อตรวจแยก หยุด service ด้วย Stop-Service (ต้อง admin) แล้ว deploy ใหม่
[ERR] ...\stage\apache missing - build first ยังไม่ได้ build ส่วนนั้น หรือ build ไม่สำเร็จ กลับไปทำตอนที่ 3, 4 หรือ 6 ให้จบ
[ERR] copy templates failed (หรือ tools) robocopy ของโฟลเดอร์นั้นคืนรหัส 8 ขึ้นไป (ไฟล์ปลายทางถูกล็อก หรือสิทธิ์ไม่พอ) ปิดโปรแกรมที่เปิดไฟล์ใน C:\APM\templates กับ tools (เช่น editor) แล้ว deploy ใหม่
[ERR] cacert.pem sha256 mismatch ไฟล์ CA bundle ที่ดาวน์โหลดไม่ตรงกับ hash ที่ curl.se ประกาศ (โหลดไม่ครบ หรือมี proxy แทรก) รัน deploy.cmd ใหม่ ถ้าเกิดซ้ำให้ตรวจเครือข่ายและ proxy
[ERR] certificate generation failed mkcert.php ออกใบรับรองไม่สำเร็จ ข้อความ OpenSSL หรือ PHP จะพิมพ์ใต้บรรทัดนี้ อ่านข้อความที่พิมพ์ต่อท้าย ตรวจว่า C:\APM\php\ext\php_openssl.dll มีอยู่ ถ้าเห็น Undefined constant OPENSSL_KEYTYPE_EC ดูแถวถัดไป
Undefined constant OPENSSL_KEYTYPE_EC หรือ [ERR] PHP openssl extension not loaded ตอนออก cert พาธติดตั้งมีวงเล็บหรือช่องว่าง ค่า -d extension_dir= เพี้ยน openssl เลยไม่โหลด ใช้ kit รุ่นใหม่ที่ครอบค่าด้วย quote หรือย้ายไปพาธที่ไม่มีอักขระพิเศษ (บั๊กที่ 5)
[ERR] mariadb-install-db failed สร้าง datadir ไม่ได้ ตรวจว่า C:\APM\data\mysql ไม่ใช่โฟลเดอร์ที่ค้างครึ่งๆ กลางๆ ถ้าเป็นให้ลบแล้ว deploy ใหม่ (ลบเฉพาะเมื่อยังไม่มีข้อมูลสำคัญ)
[ERR] httpd -t failed config มี syntax ผิด หรือ module โหลดไม่ได้ อ่านบรรทัดที่ httpd -t พิมพ์ก่อนหน้า แก้ไฟล์ที่ระบุ
httpd.exe ตายทันทีหลังสตาร์ต AH00099: could not create C:/APM/apache/logs/httpd.pid template เก่าไม่มี PidFile และโฟลเดอร์ apache\logs ไม่มีอยู่ ทำตามขั้นที่ 23 ถึง 25
ทุกหน้า PHP ตอบ 500, error.log มี End of script output before headers, php-cgi.exe -v ขึ้น Opcode handlers are unusable due to ASLR มี APM (หรือ PHP build เดียวกัน) รันอยู่อีกโฟลเดอร์ แย่งหน่วยความจำ OPcache ก้อนเดียวกัน ใช้ kit รุ่นใหม่ที่ตั้ง opcache.cache_id แยกตามโฟลเดอร์ ถ้าแก้มือให้เติมสามบรรทัด opcache ใน php.ini (บั๊กที่ 3)
Apache ขึ้น Wrapper D:/APM cannot be accessed (หรือพาธอื่นที่ถูกตัดตรงช่องว่าง) พาธติดตั้งมีช่องว่าง mod_fcgid ตัดคำสั่งตรงช่องว่าง แก้เป็น FcgidWrapper "\"${APM}/php/php-cgi.exe\"" .php หรือ deploy ด้วย kit รุ่นใหม่ (บั๊กที่ 4)
ไม่มี plugin ของ MariaDB (ha_archive, auth_ed25519) ใน C:\APM\mariadb\lib\plugin kit เก่าใส่ lib ใน /XD ของ robocopy ตัดโฟลเดอร์ lib\plugin ไปด้วย โหลด kit ใหม่แล้วรัน .\deploy.cmd อีกรอบ (บั๊กที่ 1)
เปลี่ยน -DbPort ด้วย -ResetConfig แล้วพอร์ตจริงยังเป็นเลขเก่า หน้าแรกขึ้น not reachable server อ่าน data\mysql\my.ini ซึ่ง kit เก่าไม่ sync ให้ ใช้ kit ใหม่ (-ResetConfig sync ให้และพิมพ์ (synced)) หรือแก้บรรทัด port ใน data\mysql\my.ini ด้วยมือ (บั๊กที่ 2)
สตาร์ต Apache แล้วเข้าหน้าเว็บไม่ได้ พอร์ตถูกโปรแกรมอื่นใช้อยู่ หรือ Apache ไม่ได้สตาร์ต ตรวจพอร์ตด้วย Get-NetTCPConnection -LocalPort 8080,8443 และตรวจว่ามี process httpd ของ C:\APM จริง เปลี่ยนพอร์ตด้วย -HttpPort / -HttpsPort กับ -ResetConfig
หน้าแรกขึ้น MariaDB not reachable (...) ยังไม่ได้สตาร์ต MariaDB, พอร์ตไม่ตรงกับ mysqli.default_port ใน php.ini หรือ root มีรหัสผ่านแล้ว สตาร์ตตามขั้นที่ 15 และตรวจขั้นที่ 17 ถ้าตั้งรหัสผ่านให้ root หน้านี้จะรายงานว่าเข้าไม่ได้ ซึ่งคาดไว้
เครื่องอื่นเปิดเว็บทาง LAN ไม่ได้ (timeout) httpd.conf ยังฟังที่ 127.0.0.1 (deploy เตือน [WARN] httpd.conf was kept ...) หรือยังไม่ได้เปิดกฎ firewall หรืออยู่คนละซับเน็ต ดูบรรทัด httpd.conf Listen: ว่าเป็น 0.0.0.0:... ถ้าไม่ใช่ให้ deploy.cmd -Lan -ResetConfig รัน scripts\firewall-lan.ps1 แบบ admin และตรวจว่าสองเครื่องอยู่ซับเน็ตเดียวกัน (กฎรับเฉพาะ LocalSubnet)
เครื่องอื่นเปิด HTTPS แล้ว browser เตือนใบรับรอง เครื่องนั้นยังไม่เชื่อ apm-ca.crt หรือเปิดด้วยชื่อ/IP ที่ไม่อยู่ใน SAN (IP เปลี่ยนหลัง deploy) นำเข้า apm-ca.crt ในเครื่องนั้น และถ้า IP เปลี่ยนให้ deploy.cmd -Lan -ResetConfig ออกใบรับรองใหม่
?info=1 ได้หน้าปกติ ไม่เห็น phpinfo และไม่มีลิงก์ phpinfo() เปิดจากเครื่องอื่น phpinfo เปิดให้ 127.0.0.1 และ ::1 เท่านั้น ตั้งใจให้เป็นแบบนี้ เปิดจากเครื่อง APM เอง
browser เตือนว่าใบรับรอง https://localhost:8443 ไม่น่าเชื่อถือ ยังไม่ได้นำเข้า CA ของเครื่อง (หรือ Firefox ที่ไม่ได้อ่านที่เก็บของ Windows) รัน trust-ca.ps1 (ขั้นที่ 8) หรือทำตามขั้นที่ 9 ถึง 12
00-fetch.ps1 หยุดด้วย sha256 mismatch หลังเปลี่ยนเลขของ PECL หรือ mod_fcgid hash ใหม่ไม่ตรงกับ hash เก่าใน versions.lock.json ลบบรรทัดของรายการนั้นใน lock file ตามขั้นที่ 41
00-fetch.ps1 -Only php,httpd ไม่ดาวน์โหลดอะไรเลย ไม่มี error (kit เก่า) pwsh -File ส่งรายชื่อเป็น string ก้อนเดียว และสคริปต์รุ่นเก่าไม่แยกจุลภาค โหลด kit ใหม่ สคริปต์แยกจุลภาคเองแล้ว หรือรันผ่าน -Command
[ERR] ...\src\pecl-redis-<เวอร์ชัน> missing - run 00-fetch.ps1 ตอน build PHP แก้เลขเวอร์ชัน PECL ใน versions.json แล้วยังไม่ได้ fetch รัน pwsh -NoProfile -File scripts\00-fetch.ps1 -Only pecl-redis (ชื่อตามตัวที่แก้) แล้ว build ใหม่
เปลี่ยนเลข PECL แล้วยังได้ extension รุ่นเก่า (kit เก่า) สคริปต์รุ่นเก่าคัดลอก PECL ลง build\php-pecl เฉพาะตอนยังไม่มีโฟลเดอร์ ใช้ kit ใหม่ที่ลบและคัดลอกใหม่ทุกรอบ หรือลบ build\php-pecl\<ชื่อ> เอง

แถวเรื่อง lib\plugin, พอร์ตฐานข้อมูล, พาธมีช่องว่างหรือวงเล็บ และ ASLR เป็นอาการที่ผมเจอตอนทดสอบ kit ส่วนข้อความ error ที่ผมไม่ได้ลอกจาก log จริง ผมระบุว่าให้ตรวจอะไรแทน แถวเรื่อง service, copy templates, LAN, sha256 mismatch ของ lock file และ PECL ผมสรุปมาจากการอ่านสคริปต์ ยังไม่เคยเจอกับตัวเอง แถวอื่นเป็นข้อความหรืออาการที่เกิดจริงในรอบทดสอบ หรือเป็นข้อความที่สคริปต์เขียนไว้ตรงตัว

เช็กลิสต์ปิดซีรีส์

ก่อนถือว่า APM ของคุณพร้อมใช้ ตรวจครบทั้งเจ็ดข้อนี้

  1. .\deploy.cmd จบด้วยบรรทัด [OK] APM deployed to C:\APM ... - not started และมี Syntax OK ก่อนหน้า พร้อมบรรทัด httpd.conf Listen: 127.0.0.1:8080 (หรือ 0.0.0.0:8080 ถ้าคุณตั้งใจเปิด LAN)
  2. C:\APM\ssl มี apm-ca.crt, apm-ca.key, localhost.crt, localhost.key และ cacert.pem
  3. C:\APM มี LICENSE.txt, templates, tools และ tmp\opcache และ C:\APM\mariadb\lib\plugin มีไฟล์ .dll
  4. ขั้นที่ 17 ได้ 11.8.9-MariaDB-log และ collation utf8mb4_unicode_ci
  5. ขั้นที่ 19 ได้ 200 text/html; charset=UTF-8 และขั้นที่ 20 ได้ 200
  6. หน้าแรกแสดง 8.4.27 cgi-fcgi, OPcache JIT on และเลขเวอร์ชัน MariaDB (เมื่อ MariaDB รันอยู่)
  7. ขั้นที่ 29 ไม่พิมพ์อะไรออกมา หมายถึงหยุดทุกอย่างสะอาดแล้ว และคุณมี versions.json.bak หรือสำเนาของ versions.json ที่ใช้งานได้ไว้เป็นต้นแบบการอัปเดตครั้งหน้า
ไม่อยาก build เอง

ใครอยากได้ APM ไปใช้เลยโดยไม่ต้องคอมไพล์ ผมปล่อยตัวที่ build เสร็จแล้วไว้ที่ github.com/ChonLaWan/APM-Server โหลด APM-1.0.0-win64.zip (ราว 140 MB) จากหน้า Releases เทียบ sha256 กับไฟล์ .sha256 ที่แนบไว้ แตกไว้ที่ไหนก็ได้ แล้วเปิด manager\APMManager.exe ตัว Manager จะเลือกพอร์ต เขียน config ออก cert และสร้างฐานข้อมูลให้เอง มีปุ่ม Check for updates ที่โหลดรุ่นใหม่จาก Releases มาตรวจ sha256 แล้วแทน binary ให้ โดยไม่แตะ config ฐานข้อมูล และเว็บของเรา

ซีรีส์ Build APM บน Windows

สารบัญ · ตอนก่อนหน้า: ตอนที่ 6 build PHP · ตอนถัดไป: ยังไม่มี (ตัวควบคุมแบบ apmctl จะมาในบทความแยก)

ส่วนตัวผมคิดว่าคุณค่าของทั้งซีรีส์อยู่ที่ขั้นตอนอัปเดตมากกว่าขั้นตอน build ครั้งแรก เพราะ build ครั้งแรกทำครั้งเดียว ส่วนครั้งต่อไปที่มี CVE ใหม่ คุณจะแก้ไฟล์เดียวและรอสิบกว่านาที ผมเก็บ versions.json เป็นไฟล์ที่ต้องแตะบ่อยที่สุดไว้ตั้งแต่แรก และจะเขียนเรื่องตัวควบคุม apmctl แยกต่างหากเมื่อมันใช้งานได้จริง เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

อยากให้เราทำให้ไหม?

ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า

คุยเรื่องงานกับเรา

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

ยังไม่มีความคิดเห็น — เป็นคนแรกเลย