ทำ Docker ให้ปลอดภัยบน เซิร์ฟเวอร์ เล็ก เรียงตามข้อที่คุ้มสุด และกับดัก ufw
ทำ container ให้ปลอดภัยทีละข้อ ไม่รันเป็น root ตัด capability ระบบไฟล์อ่านอย่างเดียว จำกัด memory จนถึง compose ครบชุดหนึ่งไฟล์ และสาเหตุที่ ufw ไม่กันพอร์ตของ Docker
- สี่บรรทัดใน compose (user, cap_drop, read_only, no-new-privileges) ตัดทางที่คนเจาะ container จะไปต่อได้เกือบหมด
- memory และ pids_limit กันไม่ให้ container เดียวลากทั้งเครื่องล่ม ซึ่งบนเซิร์ฟเวอร์เล็กเกิดง่ายกว่าโดนเจาะ
- พอร์ตที่ Docker publish ไม่ผ่าน ufw ต้อง bind ที่ 127.0.0.1 หรือใส่กฎใน chain DOCKER-USER
- ห้าม mount /var/run/docker.sock เข้า container ต่อให้ใส่ :ro ก็เท่ากับให้ root ของเครื่อง
Docker ทำให้ขึ้นแอปง่าย แต่ค่าเริ่มต้นออกแบบมาให้ “รันได้” ไม่ใช่ “ปลอดภัยที่สุด” บนเซิร์ฟเวอร์เล็กที่มีหลายระบบอยู่เครื่องเดียวกัน container ที่ตั้งค่าหลวมตัวเดียวพาทั้งเครื่องล่มได้ ทั้งจากคนเจาะและจากบั๊กกิน memory ธรรมดา บทความนี้เรียงสิ่งที่ควรทำตามลำดับที่คุ้มสุดก่อน จบด้วย docker-compose.yml ที่ใส่ครบทุกข้อ และกับดักที่คนใช้ ufw โดนกันเยอะ: พอร์ตที่ Docker publish ไม่ผ่าน ufw เลย
ทดสอบกับ Docker Engine 28 + Compose v2 บน Ubuntu 24.04 เขียนจากมุมคนดูแลเครื่องของตัวเอง ที่เคยเล่าเรื่องขนาดเครื่องไว้ใน เซิร์ฟเวอร์ 2GB
หลักคิด: ลดสิ่งที่ container “ทำได้” ไม่ใช่แค่กันคนเข้า
ถ้าแอปใน container มีช่องโหว่ คนเจาะจะได้สิทธิ์เท่ากับ process ในนั้น งานของเราคือทำให้สิทธิ์นั้นมีค่าน้อยที่สุด: ไม่ใช่ root, ไม่มี capability พิเศษ, เขียนดิสก์ไม่ได้, ยกระดับสิทธิ์ไม่ได้, กิน memory ได้จำกัด, และคุยกับ Docker daemon ไม่ได้ ทุกข้อด้านล่างคือการตัดหนึ่งอย่างออกจากรายการนี้
1. ไม่รันเป็น root ใน image
ค่าเริ่มต้นของ image เกือบทุกตัวคือ root ถ้าแอปโดนเจาะ คนเจาะเป็น root ใน container ทันที และถ้าหลุดออกจาก container ได้ ก็เป็น root ของเครื่อง ใส่ USER ใน Dockerfile
FROM python:3.12-slim
RUN useradd --system --uid 10001 --no-create-home app
WORKDIR /app
COPY --chown=app:app . .
USER app
CMD ["gunicorn", "-b", "0.0.0.0:8000", "main:app"]
uid สูง ๆ อย่าง 10001 ตั้งใจให้ไม่ชนกับ user จริงบนเครื่อง ส่วน image ของฐานข้อมูลอย่าง postgres มี user postgres (uid 999) อยู่แล้ว สั่งใน compose ด้วย user: "999:999" ได้เลย
2. ตัด capability ทั้งหมด แล้วเพิ่มกลับเท่าที่ต้องใช้
Linux แยกสิทธิ์ของ root เป็น capability ย่อย Docker ตัดส่วนใหญ่ให้แล้ว แต่ยังเหลือหลายตัว เช่น CHOWN, SETUID, NET_RAW แอปเว็บที่รันเป็น non-root และฟังพอร์ตเกิน 1024 ไม่ต้องใช้สักตัว
cap_drop: [ALL]
# ถ้าต้องฟังพอร์ต 80 ใน container จริง ๆ ค่อยเพิ่ม: cap_add: [NET_BIND_SERVICE]
และห้ามใช้ privileged: true ตลอดชีวิต มันคือเปิดทุก capability และอุปกรณ์ทั้งหมดของเครื่อง
3. ระบบไฟล์อ่านอย่างเดียว + tmpfs ที่ต้องเขียน
read_only: true ทำให้คนเจาะวางไฟล์ ติดตั้งเครื่องมือ หรือแก้โค้ดของแอปไม่ได้ แอปส่วนมากต้องการที่เขียนชั่วคราวแค่ /tmp ให้เป็น tmpfs (อยู่ใน RAM หายเมื่อ container หยุด) ข้อนี้พังบ่อยสุดตอนเปิดครั้งแรก เพราะ library บางตัวเขียน cache ในที่ที่เราไม่รู้ อ่าน error ตอน start แล้วเพิ่ม tmpfs ให้ path นั้น อย่าถอด read_only ทิ้ง
4. no-new-privileges
ไฟล์แบบ setuid (เช่น sudo, passwd) ยอมให้ process ธรรมดากลายเป็น root ชั่วคราว security_opt: [no-new-privileges:true] ปิดช่องนี้ทั้ง container บรรทัดเดียว แอปแทบไม่มีตัวไหนต้องการมัน
5. จำกัด memory และจำนวน process
บนเครื่องเล็ก เรื่องที่เกิดจริงบ่อยกว่าโดนเจาะคือ container ตัวเดียวกิน RAM จนเครื่องสลับ swap แล้วทุกเว็บช้าพร้อมกัน mem_limit ให้ kernel ฆ่าแค่ container นั้น และ pids_limit กัน fork bomb ไม่ว่าจะมาจากบั๊กหรือคนเจาะ
| เครื่อง 2GB | mem_limit | pids_limit |
|---|---|---|
| แอปเว็บ Python/Node | 512m | 200 |
| PostgreSQL | 768m | 200 |
| nginx บนเครื่อง (ไม่ใช่ container) | เหลือให้ OS ~700m | - |
ตั้งแล้วดู docker stats สักสัปดาห์ ค่าจริงมักต่ำกว่าที่กลัว
6. ห้าม mount Docker socket
/var/run/docker.sock คือรีโมตคอนโทรลของ Docker daemon ซึ่งรันเป็น root ใครคุยกับมันได้ ก็สั่งสร้าง container ใหม่ที่ mount / ของเครื่องเข้าไปได้ เท่ากับได้ root ของเครื่องทั้งหมด
mount socket แบบ
:roไม่ช่วยอะไร:roห้ามเขียนไฟล์ socket แต่ไม่ได้ห้ามส่งคำสั่งผ่าน socket OWASP ระบุข้อนี้เป็นกฎข้อแรกของ Docker security
เครื่องมือที่ขอ socket (dashboard, auto-updater, reverse proxy บางตัว) ให้ถามตัวเองก่อนว่าคุ้มกับการมอบ root ให้มันหรือไม่ ถ้าจำเป็นจริง ใช้ socket proxy ที่กรองคำสั่งได้ และให้มันอยู่คนเดียวบน network แยก
7. bind พอร์ตที่ 127.0.0.1
ports: ["8000:8000"] แปลว่า 0.0.0.0:8000 เปิดให้ทั้งอินเทอร์เน็ต ถ้ามีแค่ nginx บนเครื่องเดียวกันที่ต้องคุยกับแอป ให้เขียน "127.0.0.1:8000:8000" ส่วนฐานข้อมูลที่คุยกับแอปใน network เดียวกันของ compose ไม่ต้อง publish เลย ตัดบรรทัด ports ทิ้ง
8. secret ไม่อยู่ใน image และไม่โชว์ใน docker inspect
ENV DB_PASSWORD=... ใน Dockerfile ติดอยู่ใน image ทุก layer และ environment: ใน compose โชว์ใน docker inspect ให้ทุกคนที่อ่าน socket ได้ ทางที่ดีกว่าเรียงจากง่ายไปปลอดภัย
env_file: .envที่chmod 600และอยู่ใน.gitignore(ยังเห็นใน inspect แต่ไม่ติด image)- Docker secrets แบบไฟล์: compose อ่านไฟล์บนเครื่อง แล้ว mount เป็น
/run/secrets/<ชื่อ>อ่านอย่างเดียว แอปอ่านจากไฟล์แทน env ภาพpostgresรองรับPOSTGRES_PASSWORD_FILEอยู่แล้ว
กุญแจแต่ละตัวควรทำได้แค่งานของมัน หลักการนี้เล่าไว้ใน แยกกุญแจตามหน้าที่
9. ตรึง tag แล้ว rebuild ตามตาราง
python:latest วันนี้กับเดือนหน้าคนละตัว ตรึงอย่างน้อยถึง minor (python:3.12-slim) หรือตรึง digest @sha256:... ให้ไม่ขยับเลย แล้วตั้ง cron rebuild ทุกสัปดาห์เพื่อรับ patch ของ OS ใน image ความเจ็บจากของที่ไม่ตรึงเล่าไว้ใน ผ่านในเครื่อง พังบน production
เรื่องตรึงเวอร์ชันมีบทเรียนสด: มีนาคม 2026 เครื่องมือสแกน Trivy เองโดนเจาะระบบปล่อยไฟล์ (CVE-2026-33634) tag ของ GitHub Action ถูกชี้ไปโค้ดอันตราย ตัวที่ปลอดคือตัวที่ตรึง commit SHA ไว้ บทเรียนเดียวกันใช้กับ image ของเรา
10. สแกน image
# บนเครื่องที่ build image
trivy image --severity HIGH,CRITICAL myapp:1.4.2
# หรือ Docker Scout ที่มากับ Docker CLI
docker scout cves --only-severity critical,high myapp:1.4.2
ทั้งสองตัวใช้ได้ฟรีสำหรับงานขนาดนี้ ณ วันที่เขียน (โควตาและเงื่อนไขของ Scout เปลี่ยนได้ ตรวจกับเอกสารปัจจุบัน) ติดตั้ง Trivy จาก release ที่ตรวจสอบได้เท่านั้น ด้วยเหตุผลข้อ 9
11. จำกัด log
json-file ค่าเริ่มต้นไม่จำกัดขนาด แอปที่ error วนลูปเขียน log เต็มดิสก์ได้ในคืนเดียว แล้วทุกอย่างบนเครื่องพังตาม ตั้งทั้งเครื่องใน /etc/docker/daemon.json
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }
ค่าใน log-opts ต้องเป็น string และมีผลเฉพาะ container ที่สร้างหลัง restart Docker
12. ฝั่ง host และกับดัก ufw
เครื่องต้องอัปเดตเอง (unattended-upgrades) และ SSH ต้องแน่น (SSH hardening) แล้วมาถึงกับดักใหญ่
พอร์ตที่ Docker publish ไม่ผ่าน ufw
ufw deny 8000แล้วufw statusบอกว่าปิด แต่จากเครื่องอื่นยังเข้าได้
เพราะ Docker เขียนกฎ NAT ของตัวเองใน iptables และ packet ถูก route ไป container ก่อนถึง chain INPUT ที่ ufw ใช้ Docker บอกเรื่องนี้ไว้ในเอกสารตรง ๆ ไม่ใช่บั๊ก วิธีแก้เรียงตามแนะนำ
- ไม่ publish สิ่งที่ไม่ต้องเปิด ข้อ 7 แก้ได้ 90% ของกรณี
- ใส่กฎใน chain
DOCKER-USERDocker เว้น chain นี้ไว้ให้เรา และประเมินก่อนกฎของตัวเอง เช่น ให้เฉพาะ IP ออฟฟิศเข้าพอร์ตที่ publish
# แทน eth0 ด้วยชื่อ interface จริง (ip -br link)
sudo iptables -I DOCKER-USER -i eth0 ! -s 203.0.113.10 -j DROP
sudo apt install iptables-persistent && sudo netfilter-persistent save
ใส่ -i eth0 เสมอ ไม่งั้นจะบล็อก container คุยกันเองด้วย Docker 28 ขึ้นไปใช้ nftables เป็น backend ได้ กฎนี้อาจต้องเขียนต่างออกไป ตรวจกับเอกสารปัจจุบัน
ตรวจผลจากเครื่องอื่นเสมอ nmap -p 1-65535 <IP จริง> ไม่ใช่ ufw status
compose ครบชุด: แอป Python/Node + PostgreSQL
# docker-compose.yml (Docker Engine 28, Compose v2, Ubuntu 24.04)
services:
app:
image: ghcr.io/example/myapp:1.4.2 # ตรึง tag; ปลอดภัยกว่า: @sha256:...
user: "10001:10001" # ข้อ 1 non-root (USER ใน Dockerfile ด้วย)
read_only: true # ข้อ 3
tmpfs:
- /tmp:size=64m,mode=1777 # ที่เขียนชั่วคราวเดียวที่ยอม
cap_drop: [ALL] # ข้อ 2 ไม่ต้องเพิ่มกลับ ฟังพอร์ต 8000
security_opt: [no-new-privileges:true] # ข้อ 4
mem_limit: 512m # ข้อ 5
pids_limit: 200
env_file: .env # ข้อ 8 ค่าที่ไม่ลับ + chmod 600
secrets: [db_password] # แอปอ่าน /run/secrets/db_password
ports:
- "127.0.0.1:8000:8000" # ข้อ 7 nginx บนเครื่องเท่านั้น
depends_on:
db: { condition: service_healthy }
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health')"]
interval: 30s
timeout: 5s
retries: 3
logging: # ข้อ 11 (ซ้ำกับ daemon.json เผื่อย้ายเครื่อง)
options: { max-size: "10m", max-file: "3" }
restart: unless-stopped
db:
image: postgres:16.6 # ตรึง minor+patch
user: "999:999" # user postgres ใน image
read_only: true
tmpfs:
- /tmp
- /var/run/postgresql # socket ของ postgres
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
mem_limit: 768m
pids_limit: 200
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password # image รองรับ *_FILE
secrets: [db_password]
volumes:
- pgdata:/var/lib/postgresql/data # ที่เดียวที่เขียนถาวร
# ไม่มี ports: แอปคุยผ่าน network ของ compose ไม่เปิดสู่เครื่อง
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 10s
retries: 5
logging:
options: { max-size: "10m", max-file: "3" }
restart: unless-stopped
secrets:
db_password:
file: ./secrets/db_password # chmod 600, อยู่ใน .gitignore
volumes:
pgdata:
ไม่มี /var/run/docker.sock ไม่มี privileged ไม่มี network_mode: host ถ้า db ไม่ขึ้นเพราะ read_only ให้ดู log ว่าอยากเขียนที่ไหนแล้วเพิ่ม tmpfs ตรงนั้น
ก่อน / หลัง
| เรื่อง | ค่าเริ่มต้น | หลังทำ |
|---|---|---|
| user ใน container | root | uid 10001 / 999 |
| capability | ชุดเริ่มต้นของ Docker (~14 ตัว) | ไม่มีเลย |
| เขียนดิสก์ | ได้ทุกที่ | เฉพาะ tmpfs และ volume ข้อมูล |
| ยกระดับสิทธิ์ผ่าน setuid | ได้ | ไม่ได้ |
| memory / process | ไม่จำกัด | 512m-768m / 200 |
| พอร์ตแอป | 0.0.0.0 ทั้งเน็ต ข้าม ufw | 127.0.0.1 เท่านั้น |
| พอร์ตฐานข้อมูล | มักเปิด 5432 ทั้งเน็ต | ไม่ publish |
| รหัสผ่าน DB | ใน environment เห็นจาก inspect | ไฟล์ใน /run/secrets |
| image | :latest | tag ตรึง + rebuild รายสัปดาห์ + สแกน |
| log | โตไม่จำกัด | 30MB ต่อ container |
ข้อผิดพลาดที่เจอบ่อย
- เชื่อ
ufw statusแล้วไม่เคยสแกนจากเครื่องอื่น - publish พอร์ตฐานข้อมูลเพื่อ “ต่อจาก DBeaver สะดวก ๆ” ใช้ SSH tunnel แทน
- ใส่
cap_drop: [ALL]แต่ยังรันเป็น root ใน image ซึ่ง root ที่ไม่มี capability ก็ยังอ่านไฟล์ทุกไฟล์ใน container ได้ - ให้ auto-updater หรือ dashboard ขอ socket แล้วไม่คิดว่ามันคือ root ของเครื่อง
- ตั้ง
max-sizeใน daemon.json แล้วไม่ recreate container เก่า ค่าเดิมยังไม่จำกัด
Sources
คำถามที่พบบ่อย
ใส่ทั้งหมดนี้แล้วแอปจะพังไหม
ที่พังบ่อยสุดคือ read_only เพราะแอปหรือ library เขียนไฟล์ชั่วคราวในที่ที่เราไม่รู้ ดู log ตอน start แล้วเพิ่ม tmpfs ให้ path นั้น อย่าถอด read_only ทิ้ง ส่วน cap_drop ALL แอปเว็บทั่วไปที่รันเป็น non-root และฟังพอร์ตเกิน 1024 ไม่ต้องเพิ่ม capability อะไรกลับเลย
ใช้ Docker rootless แทนทั้งหมดนี้ได้ไหม
rootless ลดผลของการหลุดจาก container ได้มาก และใช้ร่วมกับทุกข้อในบทความนี้ได้ แต่มีข้อจำกัดเรื่องเครือข่ายและพอร์ตต่ำกว่า 1024 บนเซิร์ฟเวอร์เล็กที่มีคนดูแลคนเดียว เริ่มจากรายการในบทความก่อน แล้วค่อยลอง rootless เมื่อคุ้นแล้ว
ตรวจว่าทำครบแล้วยังไง
docker inspect ดูค่า User, CapDrop, ReadonlyRootfs, SecurityOpt และ Memory ของ container ที่รันอยู่ ส่วนพอร์ต ให้ยิง nmap หรือ curl จากเครื่องอื่นมาที่ IP จริง ไม่ใช่ดู ufw status บนเครื่องเอง
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ