บทความ › ความปลอดภัย

ทำ Docker ให้ปลอดภัยบน เซิร์ฟเวอร์ เล็ก เรียงตามข้อที่คุ้มสุด และกับดัก ufw

ทำ container ให้ปลอดภัยทีละข้อ ไม่รันเป็น root ตัด capability ระบบไฟล์อ่านอย่างเดียว จำกัด memory จนถึง compose ครบชุดหนึ่งไฟล์ และสาเหตุที่ ufw ไม่กันพอร์ตของ Docker

กลาง อ่าน 16 นาที 21 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • สี่บรรทัดใน 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 ไม่ได้ ทุกข้อด้านล่างคือการตัดหนึ่งอย่างออกจากรายการนี้

โฮสต์: อัปเดต + ufw (ระวัง Docker ข้าม ufw)container: non-root · cap_drop ALL · read-onlyแอป: secrets จากไฟล์ ไม่อยู่ใน image
ชั้นในสุดคือสิ่งที่ผู้โจมตีได้ ถ้าแอปพัง ยิ่งเล็กยิ่งดี

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 ไม่ว่าจะมาจากบั๊กหรือคนเจาะ

เครื่อง 2GBmem_limitpids_limit
แอปเว็บ Python/Node512m200
PostgreSQL768m200
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 ได้ ทางที่ดีกว่าเรียงจากง่ายไปปลอดภัย

  1. env_file: .env ที่ chmod 600 และอยู่ใน .gitignore (ยังเห็นใน inspect แต่ไม่ติด image)
  2. 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 บอกเรื่องนี้ไว้ในเอกสารตรง ๆ ไม่ใช่บั๊ก วิธีแก้เรียงตามแนะนำ

  1. ไม่ publish สิ่งที่ไม่ต้องเปิด ข้อ 7 แก้ได้ 90% ของกรณี
  2. ใส่กฎใน chain DOCKER-USER Docker เว้น 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 ใน containerrootuid 10001 / 999
capabilityชุดเริ่มต้นของ Docker (~14 ตัว)ไม่มีเลย
เขียนดิสก์ได้ทุกที่เฉพาะ tmpfs และ volume ข้อมูล
ยกระดับสิทธิ์ผ่าน setuidได้ไม่ได้
memory / processไม่จำกัด512m-768m / 200
พอร์ตแอป0.0.0.0 ทั้งเน็ต ข้าม ufw127.0.0.1 เท่านั้น
พอร์ตฐานข้อมูลมักเปิด 5432 ทั้งเน็ตไม่ publish
รหัสผ่าน DBใน environment เห็นจาก inspectไฟล์ใน /run/secrets
image:latesttag ตรึง + 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 บนเครื่องเอง

คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ

อ่านต่อ

#Securityเริ่มต้น

เช่าเซิร์ฟเวอร์มาใหม่ ตั้งค่า SSH ให้ปลอดภัยใน 7 ขั้นตอน

ทำทันทีหลังได้เซิร์ฟเวอร์ Ubuntu ใหม่ สร้างผู้ใช้ ใช้คีย์แทนรหัสผ่าน ปิดล็อกอิน root เปิดไฟร์วอลล์ fail2ban และอัปเดตอัตโนมัติ พร้อมกับดักที่หลายคนพลาด

อ่าน 4 นาที
#Securityกลาง

กัน CSRF โดยไม่ต้องใช้ token ด้วย Sec-Fetch-Site และ Origin (Go 1.25)

เบราว์เซอร์ยุคนี้บอกเซิร์ฟเวอร์เองว่าคำขอมาจากเว็บอื่นหรือเปล่า เช็กหัว Sec-Fetch-Site กับ Origin แทน CSRF token ได้ วิธีทำใน Go 1.25 และข้อแม้ที่ต้องรู้

อ่าน 10 นาที
#Securityกลาง

Post-quantum บน SSH และ HTTPS: ML-KEM คืออะไร และเช็กเซิร์ฟเวอร์ของเรายังไง

OpenSSH 10 ใช้ mlkem768x25519 เป็นค่าเริ่มต้นแล้ว 10.1 เตือนเมื่อไม่ได้ใช้ เบราว์เซอร์ก็ใช้ X25519MLKEM768 วิธีเช็กว่าเซิร์ฟเวอร์ตกลงใช้อะไรจริง และต้องอัปเกรดอะไร

อ่าน 8 นาที