กำลังโหลดเนื้อหา
ชุดสื่อการสอน Docker (Docker Teaching Kit)
Updates & Additions (2)
# Docker ฉบับสมบูรณ์: จากศูนย์ถึงใช้งานจริง
> เอกสารประกอบการสอน — ครอบคลุมตั้งแต่แนวคิดพื้นฐาน สถาปัตยกรรม คำสั่งใช้งาน Dockerfile, Volumes, Networking, Docker Compose ไปจนถึง Security และการใช้งานใน Production
---
## สารบัญ
1. [ทำไมต้อง Docker](#1-ทำไมต้อง-docker)
2. [Container คืออะไร — เทียบกับ Virtual Machine](#2-container-คืออะไร--เทียบกับ-virtual-machine)
3. [เทคโนโลยีเบื้องหลัง Container](#3-เทคโนโลยีเบื้องหลัง-container)
4. [สถาปัตยกรรมของ Docker](#4-สถาปัตยกรรมของ-docker)
5. [Image, Container และ Registry](#5-image-container-และ-registry)
6. [คำสั่งพื้นฐานที่ต้องใช้ให้เป็น](#6-คำสั่งพื้นฐานที่ต้องใช้ให้เป็น)
7. [Dockerfile ฉบับละเอียด](#7-dockerfile-ฉบับละเอียด)
8. [Layer Caching และ .dockerignore](#8-layer-caching-และ-dockerignore)
9. [Multi-stage Build](#9-multi-stage-build)
10. [การจัดการข้อมูล: Volumes และ Bind Mounts](#10-การจัดการข้อมูล-volumes-และ-bind-mounts)
11. [Networking](#11-networking)
12. [Docker Compose](#12-docker-compose)
13. [Registry และการจัดการ Tag](#13-registry-และการจัดการ-tag)
14. [Security](#14-security)
15. [การใช้งานใน Production](#15-การใช้งานใน-production)
16. [Troubleshooting](#16-troubleshooting)
17. [สรุป Best Practices](#17-สรุป-best-practices)
18. [ก้าวต่อไป](#18-ก้าวต่อไป)
---
## 1. ทำไมต้อง Docker
ปัญหาคลาสสิกของทีมพัฒนาซอฟต์แวร์คือประโยค **"มันรันได้บนเครื่องผมนะ"** (It works on my machine) — แอปทำงานได้ดีบนเครื่อง dev แต่พอย้ายไป test หรือ production กลับพัง สาเหตุมักมาจาก:
- เวอร์ชันของ runtime ไม่ตรงกัน (Node 18 กับ Node 22, Python 3.9 กับ 3.12)
- Library หรือ system dependency บนเครื่องไม่เหมือนกัน
- Config, environment variable, path ของไฟล์ต่างกัน
- OS คนละตัว คนละเวอร์ชัน (dev ใช้ macOS, prod ใช้ RHEL)
Docker แก้ปัญหานี้ด้วยแนวคิดเดียว: **แพ็กแอปพลิเคชัน + dependencies ทั้งหมด + config ลงใน "กล่อง" เดียว** เรียกว่า **container** แล้วรันกล่องนี้ที่ไหนก็ได้ที่มี Docker — ผลลัพธ์เหมือนกันทุกที่
เปรียบเทียบกับตู้คอนเทนเนอร์ขนส่งสินค้า: ก่อนมีตู้คอนเทนเนอร์ การขนของขึ้นเรือแต่ละชิ้นต้องจัดการต่างกันหมด แต่พอทุกอย่างอยู่ในตู้ขนาดมาตรฐาน เรือ รถบรรทุก รถไฟ เครนก็จัดการได้เหมือนกันหมด — Docker คือ "ตู้คอนเทนเนอร์มาตรฐาน" ของซอฟต์แวร์
ประโยชน์หลักที่ได้:
| ประโยชน์ | คำอธิบาย |
|---|---|
| **Consistency** | Dev, Test, Prod ใช้ image เดียวกัน ตัดปัญหา environment drift |
| **Isolation** | แต่ละแอปมี dependencies ของตัวเอง ไม่ตีกัน (Python 2 กับ 3 รันคู่กันได้) |
| **Speed** | Container start ในระดับวินาที ไม่ต้องรอ OS boot |
| **Density** | เครื่องเดียวรันได้หลายสิบ container เพราะไม่ต้องแบก Guest OS |
| **Portability** | Build ครั้งเดียว รันได้ทั้ง laptop, on-premise server, cloud |
| **Version control ของ environment** | Dockerfile คือ "โค้ด" ที่บรรยาย environment เก็บใน Git ได้ review ได้ |
---
## 2. Container คืออะไร — เทียบกับ Virtual Machine
**Container** คือ process (หรือกลุ่ม process) ที่ถูก "แยกส่วน" (isolate) ออกจากระบบส่วนอื่น โดยมี filesystem, network, process tree ของตัวเอง แต่ยังคง **ใช้ kernel ร่วมกับ Host OS**
**Virtual Machine (VM)** คือการจำลองเครื่องคอมพิวเตอร์ทั้งเครื่อง — มี virtual hardware และต้องติดตั้ง Guest OS เต็มตัวในแต่ละ VM
```
Virtual Machine Container
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌───────┐ ┌───────┐ ┌───────┐
│ App A │ │ App B │ │ App C │ │ App A │ │ App B │ │ App C │
├─────────┤ ├─────────┤ ├─────────┤ ├───────┤ ├───────┤ ├───────┤
│Bins/Libs│ │Bins/Libs│ │Bins/Libs│ │ Bins/ │ │ Bins/ │ │ Bins/ │
├─────────┤ ├─────────┤ ├─────────┤ │ Libs │ │ Libs │ │ Libs │
│Guest OS │ │Guest OS │ │Guest OS │ └───┬───┘ └───┬───┘ └───┬───┘
└────┬────┘ └────┬────┘ └────┬────┘ ┌───┴─────────┴─────────┴───┐
┌────┴────────────┴───────────┴────┐ │ Docker Engine │
│ Hypervisor │ ├───────────────────────────┤
├──────────────────────────────────┤ │ Host OS (kernel) │
│ Host OS │ ├───────────────────────────┤
├──────────────────────────────────┤ │ Infrastructure │
│ Infrastructure │ └───────────────────────────┘
└──────────────────────────────────┘
```
| หัวข้อ | Virtual Machine | Container |
|---|---|---|
| ระดับการจำลอง | จำลอง hardware ทั้งเครื่อง | แยก process ในระดับ OS |
| Guest OS | ต้องมี (แต่ละ VM) | ไม่ต้องมี — ใช้ kernel ของ host |
| ขนาด | หลาย GB | หลักสิบ–ร้อย MB (บางทีไม่กี่ MB) |
| เวลา start | นาที | วินาที / มิลลิวินาที |
| Overhead | สูง (CPU, RAM ต่อ VM) | ต่ำมาก (ใกล้เคียง native process) |
| Isolation | แข็งแรงมาก (hardware-level) | ดี แต่ต่ำกว่า VM (แชร์ kernel) |
| Density ต่อเครื่อง | หลักหน่วย–สิบ | หลักสิบ–ร้อย |
> **จุดที่คนเข้าใจผิดบ่อย:** Container **ไม่ใช่** VM ขนาดเล็ก — มันคือ process ธรรมดาบน host ที่ถูกจำกัดมุมมองและทรัพยากร ลอง `ps aux` บน host จะเห็น process ของ container ปนอยู่ด้วย
>
> **ข้อควรจำ:** Container ใช้ kernel ของ host ดังนั้น Linux container ต้องรันบน Linux kernel — บน Windows/macOS นั้น Docker Desktop จะรัน Linux VM เล็กๆ ให้เบื้องหลังโดยอัตโนมัติ
---
## 3. เทคโนโลยีเบื้องหลัง Container
Docker ไม่ได้ประดิษฐ์เทคโนโลยีใหม่ทั้งหมด แต่นำความสามารถที่มีอยู่ใน Linux kernel มาประกอบกันให้ใช้ง่าย:
### 3.1 Namespaces — "มองเห็นแค่ของตัวเอง"
Namespace ทำให้ process เห็นทรัพยากรเป็นชุดของตัวเอง แยกจาก process อื่น:
| Namespace | แยกอะไร |
|---|---|
| `pid` | Process tree — ใน container จะเห็น process ของตัวเองเป็น PID 1 |
| `net` | Network stack — มี interface, IP, routing table, port ของตัวเอง |
| `mnt` | Mount points — เห็น filesystem ของตัวเอง |
| `uts` | Hostname และ domain name |
| `ipc` | Inter-process communication (shared memory ฯลฯ) |
| `user` | User/Group ID — root ใน container ≠ root บน host ได้ |
### 3.2 Control Groups (cgroups) — "ใช้ได้แค่เท่าที่กำหนด"
cgroups จำกัดและวัดการใช้ทรัพยากรของกลุ่ม process เช่น CPU, memory, disk I/O, network — เป็นกลไกเบื้องหลัง `--memory` และ `--cpus` ของ Docker
### 3.3 Union Filesystem (OverlayFS) — "ซ้อน layer แบบประหยัดพื้นที่"
Filesystem ของ container เกิดจากการนำ layer แบบ read-only หลายชั้นมาซ้อนกัน แล้ววาง writable layer บางๆ ไว้ชั้นบนสุด (รายละเอียดในหัวข้อถัดไป)
> **สรุปหนึ่งประโยค:** Container = process ปกติ + namespaces (จำกัดการมองเห็น) + cgroups (จำกัดทรัพยากร) + union filesystem (จำกัด/จัดการดิสก์)
---
## 4. สถาปัตยกรรมของ Docker
Docker เป็นระบบแบบ client–server:
```
┌──────────────┐ REST API ┌──────────────────────────────┐ ┌──────────────┐
│ Docker CLI │──────────────▶│ Docker Daemon (dockerd) │◀──────▶│ Registry │
│ (docker ...) │ unix socket │ ├─ containerd (จัดการ │ pull/ │ (Docker Hub, │
└──────────────┘ หรือ TCP │ │ lifecycle ของ container) │ push │ Harbor ฯลฯ) │
│ └─ runc (สร้าง container │ └──────────────┘
│ จริงๆ ด้วย namespaces) │
└──────────────────────────────┘
```
- **Docker Client (`docker`)** — CLI ที่เราพิมพ์คำสั่ง แปลงเป็น REST API call ส่งให้ daemon
- **Docker Daemon (`dockerd`)** — ตัวจัดการหลัก: build image, จัดการ container/volume/network
- **containerd** — runtime ระดับกลาง จัดการ lifecycle ของ container (Kubernetes ก็ใช้ตัวนี้)
- **runc** — ตัว spawn container จริงๆ โดยเรียกใช้ namespaces/cgroups ของ kernel
- **Registry** — คลังเก็บ image เช่น Docker Hub (สาธารณะ) หรือ private registry ขององค์กร
เมื่อสั่ง `docker run nginx` สิ่งที่เกิดขึ้นตามลำดับ:
1. CLI ส่งคำสั่งให้ daemon
2. Daemon เช็คว่ามี image `nginx` ในเครื่องหรือไม่ — ถ้าไม่มี จะ `pull` จาก registry
3. สร้าง container จาก image (เพิ่ม writable layer, สร้าง network namespace, ตั้ง IP)
4. Start process ตามที่ image กำหนด (`CMD`/`ENTRYPOINT`)
---
## 5. Image, Container และ Registry
สามคำนี้ต้องแยกให้ชัดตั้งแต่วันแรก:
| คำ | ความหมาย | เปรียบเทียบ |
|---|---|---|
| **Image** | แม่แบบ read-only ที่บรรจุ filesystem + metadata (คำสั่งที่จะรัน, port, env) | Class / พิมพ์เขียว / แผ่นเกม |
| **Container** | instance ที่รันจาก image — image + writable layer + process ที่กำลังทำงาน | Object / บ้านที่สร้างจากพิมพ์เขียว / เกมที่กำลังเล่น |
| **Registry** | ที่เก็บและแจกจ่าย image | Git remote / App Store |
จาก image เดียว สร้าง container ได้กี่ตัวก็ได้ แต่ละตัวแยกขาดจากกัน
### 5.1 Image ประกอบด้วย Layers
Image ไม่ใช่ไฟล์ก้อนเดียว แต่คือ **กองของ layer แบบ read-only** ที่ซ้อนกัน — แต่ละ layer เกิดจากคำสั่งใน Dockerfile ที่เปลี่ยนแปลง filesystem (`FROM`, `RUN`, `COPY`, `ADD`)
```
┌─────────────────────────────┐
│ Writable layer (container) │ ← เกิดตอน docker run — เขียนได้ หายเมื่อลบ container
├─────────────────────────────┤
│ Layer 3: COPY . . │ ← โค้ดแอป ┐
├─────────────────────────────┤ │
│ Layer 2: RUN npm ci │ ← dependencies ├ Image (read-only)
├─────────────────────────────┤ │
│ Layer 1: FROM node:22 │ ← base image ┘
└─────────────────────────────┘
```
กลไกที่ทำให้ระบบนี้ประหยัดและเร็วคือ **Copy-on-Write (CoW):**
- Container อ่านไฟล์จาก layer ด้านล่างได้โดยตรง (ไม่ก๊อป)
- เมื่อ container จะ "แก้" ไฟล์ที่อยู่ใน layer read-only ระบบจะก๊อปไฟล์นั้นขึ้นมาที่ writable layer ก่อนแล้วค่อยแก้
- Layer ถูก **แชร์ระหว่าง image และ container** — ถ้ามี 10 container จาก image เดียวกัน ดิสก์ถูกใช้เพิ่มแค่ writable layer ของแต่ละตัว
ผลที่ตามมาที่สำคัญมาก: **ข้อมูลที่เขียนใน writable layer จะหายไปเมื่อ container ถูกลบ** — นี่คือเหตุผลที่ต้องมี Volumes (หัวข้อ 10)
### 5.2 การตั้งชื่อ Image
รูปแบบเต็ม: `[registry-host[:port]/][namespace/]repository[:tag][@digest]`
```bash
nginx # = docker.io/library/nginx:latest
nginx:1.27-alpine # ระบุ tag
ghcr.io/myorg/api:2.4.1 # registry อื่น (GitHub Container Registry)
registry.mycompany.co.th/esb/gateway:1.0.3 # private registry ภายในองค์กร
nginx@sha256:abc123... # อ้างด้วย digest — ชี้ image เดิมเป๊ะๆ เสมอ
```
> **`latest` ไม่ได้แปลว่า "ใหม่ล่าสุด"** — มันเป็นแค่ tag ปริยาย (default) เมื่อไม่ระบุ tag และชี้ไปที่ไหนก็ได้แล้วแต่คน push ล่าสุด ใน production ควรระบุเวอร์ชันชัดเจนเสมอ
---
## 6. คำสั่งพื้นฐานที่ต้องใช้ให้เป็น
### 6.1 รัน Container
```bash
# Run in foreground (see output directly, Ctrl+C to stop)
docker run nginx
# Common options combined
docker run -d \
--name web \
-p 8080:80 \
-e TZ=Asia/Bangkok \
--restart unless-stopped \
nginx:1.27-alpine
```
| Option | ความหมาย |
|---|---|
| `-d` | Detached — รันเบื้องหลัง คืน container ID มาให้ |
| `--name web` | ตั้งชื่อ container (ถ้าไม่ตั้ง Docker จะสุ่มชื่อให้) |
| `-p 8080:80` | Publish port — map **host:container** (เข้าที่ host:8080 → ส่งต่อไป container:80) |
| `-e KEY=VALUE` | ตั้ง environment variable |
| `-it` | Interactive + TTY — ใช้ตอนต้องพิมพ์โต้ตอบ เช่นเปิด shell |
| `--rm` | ลบ container อัตโนมัติเมื่อหยุด (เหมาะกับงานชั่วคราว) |
| `-v` / `--mount` | Mount volume หรือ bind mount (หัวข้อ 10) |
| `--restart unless-stopped` | ให้ Docker restart ให้อัตโนมัติเมื่อ crash หรือเครื่อง reboot |
```bash
# Interactive shell in a throwaway container
docker run -it --rm alpine:3.21 sh
```
### 6.2 ดูสถานะ
```bash
docker ps # containers that are running
docker ps -a # all containers, including stopped ones
docker images # local images
docker logs web # container logs
docker logs -f --tail 100 web # follow logs, last 100 lines
docker stats # live CPU/RAM usage per container
docker inspect web # full details in JSON (IP, mounts, env, ...)
docker top web # processes inside the container
```
### 6.3 เข้าไปใน Container ที่รันอยู่
```bash
# Open a shell inside a running container
docker exec -it web sh # alpine-based images have sh
docker exec -it web bash # debian/ubuntu-based images have bash
# Run a single command without opening a shell
docker exec web cat /etc/nginx/nginx.conf
```
> `docker exec` คือเครื่องมือ debug อันดับหนึ่ง — เข้าไปดูไฟล์ ทดสอบ network เช็ค process ได้เหมือน ssh เข้าเครื่อง
### 6.4 หยุด / ลบ / เก็บกวาด
```bash
docker stop web # graceful stop (SIGTERM, then SIGKILL after 10s)
docker start web # start a stopped container again
docker restart web
docker kill web # force kill (SIGKILL) immediately
docker rm web # remove a stopped container
docker rm -f web # force remove even if running
docker rmi nginx:1.27-alpine # remove an image
# Housekeeping
docker container prune # remove all stopped containers
docker image prune # remove dangling images (no tag)
docker image prune -a # remove ALL unused images
docker system prune # containers + networks + dangling images
docker system df # how much disk Docker is using
```
### 6.5 คัดลอกไฟล์เข้า/ออก
```bash
docker cp ./config.json web:/app/config.json # host -> container
docker cp web:/var/log/app.log ./app.log # container -> host
```
---
## 7. Dockerfile ฉบับละเอียด
**Dockerfile** คือไฟล์ text ที่บรรยายวิธีสร้าง image ทีละขั้น — เป็น "สูตรอาหาร" ที่ทำซ้ำได้เหมือนเดิมทุกครั้ง และเก็บใน Git ได้
ตัวอย่าง Dockerfile สำหรับแอป Node.js:
```dockerfile
# Base image: pin the version, use slim variant
FROM node:22-alpine
# Set working directory (created automatically if missing)
WORKDIR /app
# Copy dependency manifests first to leverage layer caching
COPY package*.json ./
# Install production dependencies only
RUN npm ci --omit=dev
# Copy application source code
COPY . .
# Document the port the app listens on (does NOT actually open it)
EXPOSE 3000
# Drop privileges: run as the built-in non-root user
USER node
# Default command (exec form)
CMD ["node", "server.js"]
```
Build และรัน:
```bash
docker build -t myapp:1.0 . # "." = build context (โฟลเดอร์ปัจจุบัน)
docker run -d -p 3000:3000 myapp:1.0
```
### 7.1 Instruction ทีละตัว
#### `FROM` — จุดเริ่มต้นของทุก image
```dockerfile
FROM node:22-alpine
FROM golang:1.24-alpine AS builder # ตั้งชื่อ stage (ใช้ใน multi-stage)
FROM scratch # image เปล่าสนิท (สำหรับ static binary)
```
หลักการเลือก base image: **เล็กที่สุดที่ยังทำงานได้** — `alpine` (~5 MB) < `slim` (~80 MB) < ตัวเต็ม (หลายร้อย MB) และ **pin เวอร์ชันเสมอ** อย่าใช้ `latest`
#### `WORKDIR` — กำหนด directory ทำงาน
```dockerfile
WORKDIR /app # ทุก RUN/COPY/CMD หลังจากนี้ทำงานใน /app
```
ใช้ `WORKDIR` แทนการ `RUN cd /app` (ซึ่งไม่มีผลข้าม layer)
#### `COPY` vs `ADD`
```dockerfile
COPY src/ /app/src/ # copy from build context into image
COPY --chown=node:node . . # copy and set owner in one step
ADD https://example.com/file.tgz /tmp/ # ADD can fetch URLs
ADD archive.tar.gz /opt/ # ADD auto-extracts local tar files
```
> **กฎง่ายๆ:** ใช้ `COPY` เป็นหลักเสมอ — พฤติกรรมตรงไปตรงมา ใช้ `ADD` เฉพาะเมื่อต้องการความสามารถแตก tar อัตโนมัติจริงๆ (การดึงไฟล์จาก URL ควรใช้ `RUN curl/wget` เพื่อควบคุม checksum ได้)
#### `RUN` — รันคำสั่งตอน build (สร้าง layer ใหม่)
```dockerfile
# Each RUN creates one layer -> chain related commands with &&
RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates && \
rm -rf /var/lib/apt/lists/*
```
เหตุที่ต้องล้าง cache (`rm -rf /var/lib/apt/lists/*`) **ในคำสั่ง RUN เดียวกัน**: ถ้าแยกไปอีก `RUN` หนึ่ง ไฟล์จะยังอยู่ใน layer ก่อนหน้า — ขนาด image ไม่ลด
#### `ENV` vs `ARG`
```dockerfile
ARG APP_VERSION=dev # build-time only: docker build --build-arg APP_VERSION=1.2.3
ENV TZ=Asia/Bangkok # available at build time AND runtime
ENV APP_VERSION=$ARG_... # persist an ARG into runtime if needed
```
| | `ARG` | `ENV` |
|---|---|---|
| มีผลตอน build | มี | มี |
| มีผลตอน runtime (ใน container) | **ไม่มี** | มี |
| Override ได้ด้วย | `--build-arg` | `docker run -e` |
> **ห้ามใส่ secret ใน ENV/ARG** — ค่าจะติดอยู่ใน image metadata ดูได้ด้วย `docker history` / `docker inspect`
#### `EXPOSE` — เอกสาร ไม่ใช่การเปิด port
```dockerfile
EXPOSE 8080
```
`EXPOSE` เป็นเพียง metadata บอกว่าแอปฟัง port ไหน — **ไม่ได้เปิด port ให้ภายนอกเข้าถึง** การเปิดจริงต้องใช้ `-p` ตอน `docker run`
#### `CMD` vs `ENTRYPOINT` — คู่ที่สับสนที่สุด
| | `CMD` | `ENTRYPOINT` |
|---|---|---|
| บทบาท | คำสั่ง/argument **ปริยาย** | คำสั่ง **หลักตายตัว** ของ container |
| ถูกแทนที่เมื่อ | ผู้ใช้ใส่ argument ท้าย `docker run` | ต้องใช้ `--entrypoint` ถึงจะเปลี่ยน |
ใช้คู่กัน: `ENTRYPOINT` เป็นตัวโปรแกรม, `CMD` เป็น argument ปริยาย
```dockerfile
ENTRYPOINT ["ping"]
CMD ["localhost"]
```
```bash
docker run myping # runs: ping localhost
docker run myping google.com # runs: ping google.com (CMD ถูกแทนที่)
```
**Exec form vs Shell form:**
```dockerfile
CMD ["node", "server.js"] # exec form (recommended): node becomes PID 1
CMD node server.js # shell form: runs as "/bin/sh -c ..." -> sh is PID 1
```
ใช้ **exec form** เสมอ — ถ้าใช้ shell form ตัว shell จะเป็น PID 1 และมักไม่ส่งต่อ SIGTERM ให้แอป ทำให้ `docker stop` ต้องรอ timeout แล้วโดน SIGKILL (graceful shutdown ไม่ทำงาน)
#### `USER` — อย่ารันเป็น root
```dockerfile
# Debian/Ubuntu base
RUN groupadd -r app && useradd -r -g app app
# Alpine base
RUN addgroup -S app && adduser -S app -G app
USER app
```
ค่าปริยายของ container คือ root — ถ้าแอปโดนเจาะ ผู้โจมตีได้ root ใน container ทันที (และเป็นบันไดต่อไปยัง host ได้ในบางกรณี)
#### `HEALTHCHECK` — ให้ Docker รู้ว่าแอป "ใช้งานได้จริง" ไม่ใช่แค่ process ยังอยู่
```dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:8080/health || exit 1
```
สถานะจะแสดงใน `docker ps` เป็น `healthy` / `unhealthy` และใช้ร่วมกับ `depends_on: condition: service_healthy` ใน Compose ได้
#### อื่นๆ ที่ควรรู้
```dockerfile
LABEL org.opencontainers.image.source="https://github.com/myorg/myapp"
VOLUME /data # declare a mount point (anonymous volume if not overridden)
SHELL ["/bin/bash", "-c"]
STOPSIGNAL SIGQUIT # e.g. nginx prefers SIGQUIT for graceful stop
```
---
## 8. Layer Caching และ .dockerignore
### 8.1 กลไก Cache ตอน Build
Docker build ทีละ instruction และ **cache ผลลัพธ์ของแต่ละ layer** — ถ้า instruction และ input ไม่เปลี่ยน จะใช้ cache เดิม (เห็นเป็น `CACHED` ใน log) แต่ **ถ้า layer ใดแตก cache — ทุก layer ถัดจากนั้นต้อง build ใหม่ทั้งหมด**
จึงเกิดหลักการทอง: **เรียงจากสิ่งที่เปลี่ยนน้อย → เปลี่ยนบ่อย**
```dockerfile
# BAD: any code change invalidates the npm install layer
COPY . .
RUN npm ci
# GOOD: npm install layer is reused as long as package files are unchanged
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
```
ผลลัพธ์จริง: แก้โค้ด 1 บรรทัดแล้ว build ใหม่ — แบบแรกรอ `npm ci` ทุกครั้ง (อาจเป็นนาที) แบบหลังเสร็จในไม่กี่วินาที
### 8.2 `.dockerignore`
Build context (ทุกไฟล์ในโฟลเดอร์ที่ระบุตอน `docker build`) จะถูกส่งให้ daemon ทั้งก้อน — ไฟล์ที่ไม่เกี่ยวทำให้ build ช้าและเสี่ยงหลุดเข้า image
```
# .dockerignore
.git
node_modules
dist
*.log
.env
.env.*
Dockerfile
docker-compose*.yml
README.md
```
> `.env` ต้องอยู่ใน `.dockerignore` **เสมอ** — มิฉะนั้น `COPY . .` จะพา secret เข้า image ไปตลอดกาล (อยู่ใน layer แม้ภายหลังจะ `RUN rm` ทิ้ง)
---
## 9. Multi-stage Build
ปัญหา: การ build แอปต้องใช้เครื่องมือเยอะ (compiler, SDK, dev dependencies) แต่ตอน **รัน** ต้องการแค่ผลลัพธ์สุดท้าย — ถ้า build ใน image เดียว ทุกอย่างจะติดไปด้วย ได้ image อ้วน 800 MB – 1 GB+ พร้อมพื้นที่โจมตี (attack surface) มหาศาล
**Multi-stage build** แก้ด้วยการใช้หลาย `FROM` ในไฟล์เดียว — stage แรกไว้ build, stage สุดท้ายก๊อปเฉพาะผลลัพธ์มา:
```dockerfile
# ---------- Build stage ----------
FROM golang:1.24-alpine AS builder
WORKDIR /src
# Cache dependencies first
COPY go.mod go.sum ./
RUN go mod download
# Build a fully static binary (no CGO) so it runs on minimal images
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app .
# ---------- Runtime stage ----------
FROM alpine:3.21
RUN addgroup -S app && adduser -S app -G app && \
apk add --no-cache ca-certificates tzdata
COPY --from=builder /out/app /usr/local/bin/app
USER app
EXPOSE 8080
ENTRYPOINT ["app"]
```
ผลลัพธ์ (แอป Go ตัวเดียวกัน):
| วิธี | ขนาด image โดยประมาณ |
|---|---|
| Single-stage บน `golang:1.24` | **~900 MB** |
| Multi-stage → `alpine` | **~15–25 MB** |
| Multi-stage → `scratch` / distroless | **~8–15 MB** |
ประโยชน์นอกจากขนาด: ใน image สุดท้าย **ไม่มี compiler, ไม่มี source code, ไม่มี dev tools** — ปลอดภัยขึ้นอย่างมีนัยสำคัญ
ตัวเลือก base สำหรับ stage สุดท้าย:
- **`alpine`** — เล็ก มี shell/apk ให้ debug ได้ (ระวังเรื่อง musl libc กับบาง binary)
- **`gcr.io/distroless/static`** — ไม่มี shell ไม่มี package manager เหมาะ production ที่เน้น security
- **`scratch`** — ว่างเปล่า 100% ใช้ได้กับ static binary เท่านั้น (ต้องก๊อป ca-certificates เองถ้าเรียก HTTPS)
---
## 10. การจัดการข้อมูล: Volumes และ Bind Mounts
หลักที่ต้องจำ: **container เป็นของชั่วคราว (ephemeral)** — ข้อมูลใน writable layer หายทันทีที่ลบ container ถ้าข้อมูลต้องอยู่รอด ต้องเก็บนอก container
| แบบ | คืออะไร | เหมาะกับ |
|---|---|---|
| **Volume** | พื้นที่ที่ Docker จัดการเอง (อยู่ใต้ `/var/lib/docker/volumes/`) | ข้อมูล production: database, ไฟล์อัปโหลด |
| **Bind mount** | ผูก directory จริงบน host เข้า container | Development (mount โค้ดเข้าไปแก้สดๆ), ไฟล์ config |
| **tmpfs** | เก็บใน RAM หายเมื่อ container หยุด | ข้อมูลชั่วคราว/ความลับที่ไม่อยากลงดิสก์ |
### 10.1 Volumes
```bash
docker volume create pgdata
docker volume ls
docker volume inspect pgdata
# Attach the volume to a container (-v name:path-in-container)
docker run -d --name db \
-e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data \
postgres:17-alpine
# Delete the container -> data survives in the volume
docker rm -f db
docker run -d --name db2 -e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data postgres:17-alpine # data is back
docker volume rm pgdata # deleting the volume is what destroys data
docker volume prune # remove unused volumes (careful!)
```
### 10.2 Bind Mounts
```bash
# Mount current directory into the container (great for development)
docker run -d -p 8080:80 \
-v "$(pwd)/site:/usr/share/nginx/html:ro" \
nginx:1.27-alpine
```
`:ro` = read-only — แนะนำสำหรับ config/ไฟล์ที่ container ไม่ควรแก้
> **ข้อควรระวัง bind mount:** ขึ้นกับ path บนเครื่องนั้นๆ (พังเมื่อย้ายเครื่อง), เรื่อง permission/UID ระหว่าง host กับ container, และประสิทธิภาพบน Docker Desktop (macOS/Windows) ช้ากว่า volume
---
## 11. Networking
### 11.1 Network Drivers หลัก
| Driver | พฤติกรรม |
|---|---|
| `bridge` (default) | Container ต่อกับ virtual switch บน host — โลกภายนอกเข้าถึงได้ผ่าน `-p` เท่านั้น |
| `host` | ใช้ network ของ host ตรงๆ ไม่มี isolation, ไม่ต้อง `-p` (Linux เท่านั้น) |
| `none` | ไม่มี network เลย |
| `overlay` | เชื่อม container ข้ามหลายเครื่อง (Swarm/cluster) |
### 11.2 User-defined Bridge — สิ่งที่ควรใช้จริง
ความต่างสำคัญ: **default bridge ไม่มี DNS ระหว่าง container** แต่ **network ที่เราสร้างเองมี built-in DNS** — container เรียกหากันด้วย "ชื่อ" ได้เลย
```bash
docker network create appnet
docker run -d --name db --network appnet -e POSTGRES_PASSWORD=secret postgres:17-alpine
docker run -d --name api --network appnet -p 8080:8080 myapp:1.0
# Inside "api", the hostname "db" resolves to the db container's IP
docker exec -it api sh
/ # nc -zv db 5432 # connect to Postgres by container name
```
จุดที่พลาดบ่อยที่สุดของมือใหม่: ตั้งค่าแอปให้ต่อ database ที่ `localhost:5432` — **ผิด** เพราะ `localhost` ใน container คือตัว container เอง ต้องใช้ **ชื่อ service/container** (`db:5432`)
```bash
docker network ls
docker network inspect appnet
docker network connect appnet existing-container
docker network disconnect appnet existing-container
```
### 11.3 Port Publishing
```bash
-p 8080:80 # host 8080 -> container 80 (ทุก interface)
-p 127.0.0.1:8080:80 # bind เฉพาะ localhost ของ host (ปลอดภัยขึ้นสำหรับ internal service)
-p 8080:80/udp # ระบุโปรโตคอล
-P # publish ทุก port ที่ EXPOSE ไว้ ไปยัง random port
```
> **ข้อควรระวังด้าน security:** บน Linux การ `-p 3306:3306` เปิดสู่ทุก interface และ Docker เขียน iptables ให้เอง ซึ่ง **อาจลัดผ่านกฎ firewall อย่าง ufw** — database/บริการภายในควร bind เป็น `127.0.0.1:port:port` หรือไม่ publish เลยแล้วให้คุยกันใน network ภายใน
---
## 12. Docker Compose
พอระบบมีหลาย container (api + db + cache) การพิมพ์ `docker run` ยาวๆ ทีละตัวไม่ไหว — **Docker Compose** ให้เราประกาศทั้งระบบในไฟล์ YAML เดียว แล้วคุมด้วยคำสั่งเดียว
> Compose รุ่นปัจจุบันเป็น plugin ของ Docker CLI — ใช้ `docker compose` (มีเว้นวรรค) ไม่ใช่ `docker-compose` แบบเก่า และชื่อไฟล์มาตรฐานคือ `compose.yaml` (ยังอ่าน `docker-compose.yml` ได้) โดยไม่ต้องใส่ key `version:` แล้ว
### 12.1 ตัวอย่างระบบจริง: API + PostgreSQL + Adminer
```yaml
# compose.yaml
services:
api:
build: . # build from Dockerfile in this directory
image: myapp:1.0
ports:
- "8080:8080"
environment:
DB_HOST: db # talk to Postgres by service name
DB_USER: app
DB_PASSWORD: ${DB_PASSWORD} # read from .env file or shell env
DB_NAME: appdb
depends_on:
db:
condition: service_healthy # wait until db is actually ready
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: appdb
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
adminer: # web UI for inspecting the database
image: adminer:4
ports:
- "8081:8080"
depends_on:
- db
volumes:
db_data: # named volume managed by Docker
```
ไฟล์ `.env` วางข้างๆ `compose.yaml`:
```
DB_PASSWORD=changeme-in-real-life
```
### 12.2 คำสั่ง Compose ที่ใช้ประจำ
```bash
docker compose up -d # create & start everything in background
docker compose ps # status of services in this project
docker compose logs -f api # follow logs of one service
docker compose exec api sh # shell into a running service
docker compose build # rebuild images
docker compose up -d --build # rebuild + restart in one go
docker compose restart api
docker compose down # stop & remove containers + network (volumes survive)
docker compose down -v # ...AND delete volumes (data is gone!)
docker compose config # render the final merged/validated config
```
### 12.3 แนวคิดสำคัญใน Compose
- **Service name = DNS name** — Compose สร้าง network ให้โปรเจกต์อัตโนมัติ ทุก service เรียกหากันด้วยชื่อ
- **`depends_on` เปล่าๆ คุมแค่ "ลำดับ start"** ไม่รอให้พร้อมใช้งาน — ต้องผูกกับ `condition: service_healthy` + `healthcheck` ถึงจะรอจริง
- **`down` ไม่ลบ volume** (ข้อมูลอยู่) แต่ **`down -v` ลบ** — จำให้ขึ้นใจก่อนใช้กับข้อมูลจริง
- แยก config ตาม environment ได้ด้วยหลายไฟล์: `docker compose -f compose.yaml -f compose.prod.yaml up -d`
---
## 13. Registry และการจัดการ Tag
### 13.1 Push ขึ้น Registry
```bash
docker login # Docker Hub
docker login registry.mycompany.co.th # private registry
# Tag = pointer: same image can have many tags
docker tag myapp:1.0 registry.mycompany.co.th/team/myapp:1.0
docker push registry.mycompany.co.th/team/myapp:1.0
docker pull registry.mycompany.co.th/team/myapp:1.0
```
### 13.2 กลยุทธ์การตั้ง Tag
```
myapp:1.4.2 # semantic version — immutable, ใช้ deploy production
myapp:1.4 # ตามสายเวอร์ชัน
myapp:git-3fa9c21 # ผูกกับ commit — traceability สูงสุด (เหมาะกับ CI/CD)
myapp:latest # ใช้เพื่อความสะดวกใน dev เท่านั้น
```
หลักการ: **production ต้อง deploy ด้วย tag ที่ระบุตัว image ได้เป๊ะ** (เวอร์ชันเต็มหรือ digest) — `latest` ทำให้ "deploy เดิมซ้ำ" อาจได้คนละ image และ rollback ลำบาก
### 13.3 Private Registry
ตัวเลือกยอดนิยมในองค์กร: **Harbor** (มี UI, RBAC, vulnerability scan), **GitLab/GitHub Container Registry**, cloud registry (ECR/ACR/GAR) หรือรันแบบ minimal:
```bash
docker run -d -p 5000:5000 --name registry -v regdata:/var/lib/registry registry:2
```
---
## 14. Security
หลักคิด: image ที่ดีคือ image ที่ **เล็ก, ไม่รันเป็น root, ไม่มี secret ฝังอยู่, และรู้ว่าข้างในมีอะไรบ้าง**
### 14.1 Checklist ความปลอดภัยของ Image
1. **รันด้วย non-root user** — ใส่ `USER` เสมอ (หัวข้อ 7)
2. **Base image เล็กและ pin เวอร์ชัน** — alpine/slim/distroless ลด CVE ที่ติดมา
3. **Multi-stage** — ไม่พก compiler/dev tools ไป production
4. **ห้ามฝัง secret ใน image** — ไม่ใส่ใน `ENV`, `ARG`, ไม่ `COPY .env` (ตรวจย้อนหลังด้วย `docker history <image>`)
5. **สแกนช่องโหว่ก่อนใช้/ก่อน push:**
```bash
# Trivy (open source, นิยมใน CI)
trivy image myapp:1.0
# Docker Scout (มากับ Docker)
docker scout quickview myapp:1.0
docker scout cves myapp:1.0
```
### 14.2 ทำให้ Runtime แข็งแรงขึ้น
```bash
docker run -d \
--read-only \ # root filesystem อ่านอย่างเดียว
--tmpfs /tmp \ # เปิดพื้นที่เขียนเฉพาะจุดที่จำเป็น
--cap-drop ALL \ # ถอด Linux capabilities ทั้งหมด
--cap-add NET_BIND_SERVICE \ # เพิ่มคืนเฉพาะที่ต้องใช้
--security-opt no-new-privileges \ # กัน privilege escalation
--memory 512m --cpus 1.5 \ # จำกัดทรัพยากร
myapp:1.0
```
### 14.3 ส่ง Secret อย่างถูกวิธี
- **Runtime:** ใช้ environment variable ที่ inject ตอน run (จากไฟล์ `.env` ที่ไม่เข้า Git / secret manager เช่น Vault) หรือ mount secret file เข้า container
- **Build time:** ใช้ BuildKit secret mount — ค่าไม่ติดเข้า layer:
```dockerfile
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
```
```bash
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .
```
### 14.4 ข้อห้ามที่เจอบ่อย
- อย่า `docker run --privileged` โดยไม่จำเป็นจริงๆ — เท่ากับยก host ให้ container
- อย่า mount `/var/run/docker.sock` เข้า container ทั่วไป — ใครคุม socket = คุม host
- อย่าเปิด Docker daemon TCP (`2375`) โดยไม่มี TLS — เท่ากับเปิด root shell สู่สาธารณะ
---
## 15. การใช้งานใน Production
### 15.1 Restart Policy
```bash
--restart no # ค่าปริยาย
--restart on-failure[:N] # restart เมื่อ exit code != 0 (จำกัดจำนวนครั้งได้)
--restart unless-stopped # restart เสมอ ยกเว้นเราสั่ง stop เอง (แนะนำสำหรับ service)
--restart always # เหมือนบน แต่ boot เครื่องแล้วขึ้นด้วยแม้เคยถูก stop
```
### 15.2 จำกัดทรัพยากร
```bash
docker run -d --memory 512m --memory-swap 512m --cpus 1.5 myapp:1.0
docker stats # ตรวจการใช้จริง
```
ถ้าไม่จำกัด — container เดียวที่ memory leak สามารถลากทั้งเครื่องล่มได้ เมื่อ container ใช้ RAM เกิน limit จะถูก kernel ฆ่า (**OOMKilled**, exit code **137**)
### 15.3 Logging
Docker default ใช้ `json-file` driver และ **ไม่จำกัดขนาด** — production ต้องตั้ง rotation เสมอ ไม่งั้นดิสก์เต็มแน่นอน:
```json
// /etc/docker/daemon.json
{
"log-driver": "json-file",
"log-opts": { "max-size": "50m", "max-file": "5" }
}
```
หลักการฝั่งแอป: **log ออก stdout/stderr** อย่าเขียนลงไฟล์ใน container — ให้ Docker/ระบบรวบรวม log (ELK, Loki, Fluentd) เป็นคนจัดการ
### 15.4 หลักออกแบบแอปให้เข้ากับ Container
- **1 container = 1 หน้าที่หลัก** — แยก web / worker / db ไม่ยัดทุกอย่างใส่กล่องเดียว
- **Stateless ให้มากที่สุด** — state ไปอยู่ใน database/volume/object storage
- **Config ผ่าน environment variables** — image เดียว รันได้ทุก environment
- **จัดการ SIGTERM** — ปิด connection ให้เรียบร้อยภายใน grace period (default 10s) ก่อนโดน SIGKILL
- **มี `/health` endpoint** — ต่อกับ HEALTHCHECK/orchestrator
---
## 16. Troubleshooting
### 16.1 ลำดับการไล่ปัญหา
```bash
docker ps -a # 1) container สถานะอะไร? exit code เท่าไร?
docker logs --tail 200 <name> # 2) แอปพูดว่าอะไรก่อนตาย?
docker inspect <name> # 3) config ถูกไหม? (env, mounts, network, OOMKilled?)
docker exec -it <name> sh # 4) เข้าไปดูข้างใน (ถ้ายังรันอยู่)
docker events # 5) ดูเหตุการณ์ระดับ daemon แบบ real-time
docker stats # 6) ทรัพยากรตันหรือเปล่า?
```
### 16.2 Exit Codes ที่ควรจำ
| Code | ความหมาย | ทางไปต่อ |
|---|---|---|
| `0` | จบปกติ | ปกติสำหรับ job/one-shot; ถ้าเป็น service = แอปจบเองโดยไม่ตั้งใจ |
| `1` | Error ทั่วไปในแอป | ดู `docker logs` |
| `125` | Docker รันคำสั่งไม่ได้ | option ของ `docker run` ผิด |
| `126` | Execute ไม่ได้ | ไฟล์ไม่มีสิทธิ์ execute / ไม่ใช่ executable |
| `127` | หา command ไม่เจอ | path ผิด / binary ไม่มีใน image (เช่นใช้ bash บน alpine) |
| `137` | SIGKILL | มักคือ **OOMKilled** — เช็ค `docker inspect` ฟิลด์ `OOMKilled` |
| `139` | Segmentation fault | ปัญหาระดับ binary/library |
| `143` | SIGTERM | ถูกสั่ง stop ตามปกติ |
### 16.3 อาการยอดฮิตและวิธีแก้
| อาการ | สาเหตุที่พบบ่อย | วิธีแก้ |
|---|---|---|
| `port is already allocated` | port บน host ถูกใช้แล้ว | เปลี่ยน port ฝั่ง host หรือหาตัวจอง: `ss -tlnp \| grep 8080` |
| Container ขึ้นแล้วดับทันที | process หลักจบ (เช่น รัน daemon แบบ background แล้ว PID 1 จบ) | รันแอปแบบ foreground; ดู logs |
| ต่อ DB ไม่ได้ | ใช้ `localhost` แทนชื่อ service / อยู่คนละ network | ใช้ชื่อ service + network เดียวกัน |
| `permission denied` ตอนเขียน volume | UID ใน container ไม่ตรง owner ของโฟลเดอร์ | `COPY --chown` / ปรับ owner / กำหนด `user:` |
| ข้อมูลหายหลัง restart | เขียนลง writable layer ไม่ได้ใช้ volume | ผูก volume ให้ path ข้อมูล |
| Build ช้าทุกครั้ง | ลำดับ COPY ทำ cache แตก / context ใหญ่ | จัดลำดับ Dockerfile + `.dockerignore` |
| `Cannot connect to the Docker daemon` | daemon ไม่รัน / user ไม่อยู่ group docker | `systemctl status docker`; เพิ่ม user เข้า group `docker` |
| ดิสก์เต็ม | image/log สะสม | `docker system df` → `docker system prune`; ตั้ง log rotation |
---
## 17. สรุป Best Practices
**Dockerfile**
1. Pin เวอร์ชัน base image — ห้าม `latest` ใน production
2. เลือก base เล็กที่สุดที่พอ (alpine/slim/distroless)
3. เรียง instruction ให้ cache ทำงาน: dependencies ก่อน source code
4. ใช้ multi-stage แยก build ออกจาก runtime
5. `USER` non-root เสมอ, ใช้ exec form สำหรับ `CMD`/`ENTRYPOINT`
6. มี `.dockerignore` และห้าม secret เข้า image
7. ใส่ `HEALTHCHECK` ให้ service
**Runtime / Operations**
8. Container = ephemeral — ข้อมูลถาวรอยู่ใน volume เท่านั้น
9. ใช้ user-defined network, เรียกกันด้วยชื่อ service
10. จำกัด memory/CPU และตั้ง restart policy
11. ตั้ง log rotation ตั้งแต่วันแรก
12. สแกน image (trivy/scout) ใน CI ก่อน push
13. Deploy ด้วย tag เวอร์ชันชัดเจน เพื่อ rollback ได้เสมอ
14. ระบบหลาย service → ใช้ Compose ประกาศไว้ในไฟล์ ไม่พิมพ์มือ
---
## 18. ก้าวต่อไป
Docker + Compose เพียงพอสำหรับเครื่องเดียว แต่เมื่อระบบต้องการ **หลายเครื่อง, auto-scaling, self-healing, rolling update** — นั่นคือหน้าที่ของ **Container Orchestrator**:
- **Kubernetes (K8s)** — มาตรฐานอุตสาหกรรม: Pod, Deployment, Service, Ingress ล้วนต่อยอดจากแนวคิด container/image/registry ที่เรียนมาทั้งหมด
- **Docker Swarm** — ง่ายกว่า ใช้ syntax ใกล้ Compose เหมาะระบบเล็ก
- แนวเรียนต่อ: Docker → Compose → Kubernetes พื้นฐาน → Helm / GitOps → Observability
แหล่งอ้างอิงหลัก: [docs.docker.com](https://docs.docker.com) · [Dockerfile reference](https://docs.docker.com/reference/dockerfile/) · [Compose reference](https://docs.docker.com/compose/) · [Play with Docker](https://labs.play-with-docker.com) (สนามซ้อมฟรีในเบราว์เซอร์)
# Docker Labs และเคสตัวอย่างประกอบการสอน
> คู่มือปฏิบัติ 7 แล็บ (เรียงจากง่ายไปยาก) + 5 เคสสถานการณ์จริงสำหรับชวนผู้เรียนวิเคราะห์ — แต่ละแล็บมีเป้าหมาย ขั้นตอน จุดสังเกต และคำถามท้ายแล็บพร้อมแนวเฉลย
**เตรียมเครื่องผู้เรียน:** Docker Engine หรือ Docker Desktop ที่รัน `docker run hello-world` ผ่าน · ถ้าเครื่องไม่พร้อม ใช้ [labs.play-with-docker.com](https://labs.play-with-docker.com) ได้ (ฟรี มีอายุ session 4 ชม.)
---
## Lab 0: ตรวจความพร้อม (10 นาที)
```bash
docker version # client + server version
docker info # daemon configuration summary
docker run hello-world # end-to-end test: pull -> create -> run
```
**ชวนสังเกต:** ตอนรัน `hello-world` ครั้งแรกจะเห็นบรรทัด `Unable to find image ... locally` ตามด้วย `Pulling from library/hello-world` — นี่คือขั้นตอน pull อัตโนมัติ ครั้งที่สองรันซ้ำจะไม่ pull แล้ว
---
## Lab 1: รัน Container แรก — nginx (25 นาที)
**เป้าหมาย:** เข้าใจ lifecycle ของ container, port mapping, logs, exec
```bash
# 1) Run nginx in the background, map host 8080 -> container 80
docker run -d --name web -p 8080:80 nginx:1.27-alpine
# 2) Open http://localhost:8080 in a browser -> nginx welcome page
# 3) Inspect what's running
docker ps
docker logs web
docker logs -f web # refresh the browser and watch access logs live (Ctrl+C to exit)
# 4) Go inside the container
docker exec -it web sh
ls /usr/share/nginx/html
# Change the homepage from inside the container
echo '<h1>Hello from container!</h1>' > /usr/share/nginx/html/index.html
exit
# Refresh browser -> new page
# 5) Prove that containers are ephemeral
docker rm -f web
docker run -d --name web -p 8080:80 nginx:1.27-alpine
# Refresh browser -> back to the default page. Our edit is GONE.
# 6) Clean up
docker rm -f web
```
**คำถามท้ายแล็บ**
1. ทำไมหน้าเว็บที่แก้ไว้ถึงหายไปในข้อ 5? → *แก้ใน writable layer ของ container ตัวเก่า ซึ่งถูกลบไปพร้อม container — image ต้นทางไม่เคยถูกแก้*
2. `-p 8080:80` เลขไหนคือฝั่ง host เลขไหนคือฝั่ง container? → *ซ้าย host : ขวา container*
3. ลองรัน container ที่สองด้วย `-p 8080:80` เหมือนกัน — เกิดอะไรขึ้น เพราะอะไร? → *Error `port is already allocated` เพราะ port ฝั่ง host ซ้ำกันไม่ได้ (เปลี่ยนเป็น 8081:80 ได้)*
---
## Lab 2: Build Image แรกด้วย Dockerfile (40 นาที)
**เป้าหมาย:** เขียน Dockerfile, เข้าใจ build context และเห็น layer caching ด้วยตาตัวเอง
สร้างโฟลเดอร์ `lab2/` แล้วสร้าง 3 ไฟล์นี้:
```javascript
// server.js — tiny Express app for the lab
const express = require("express");
const os = require("os");
const app = express();
const PORT = process.env.PORT || 3000;
app.get("/", (req, res) => {
res.json({
message: "Hello from Docker!",
hostname: os.hostname(), // inside a container this is the container ID
version: process.env.APP_VERSION || "dev",
});
});
app.listen(PORT, () => console.log(`Listening on :${PORT}`));
```
```json
{
"name": "lab2-app",
"version": "1.0.0",
"main": "server.js",
"dependencies": { "express": "^4.19.0" }
}
```
```dockerfile
# Dockerfile
FROM node:22-alpine
WORKDIR /app
# Copy dependency manifests first -> this layer is cached
COPY package*.json ./
RUN npm install --omit=dev
# Copy application source (changes often -> keep it late)
COPY . .
EXPOSE 3000
USER node
CMD ["node", "server.js"]
```
และไฟล์ `.dockerignore`:
```
node_modules
*.log
.git
```
**ขั้นตอน:**
```bash
cd lab2
docker build -t lab2-app:1.0 .
docker run -d --name lab2 -p 3000:3000 -e APP_VERSION=1.0 lab2-app:1.0
curl http://localhost:3000
```
**การทดลอง layer cache (หัวใจของแล็บนี้):**
```bash
# Round 1: build again without changing anything
docker build -t lab2-app:1.0 .
# -> every step shows CACHED, finishes in about a second
# Round 2: edit only the message text in server.js, then build
docker build -t lab2-app:1.1 .
# -> COPY package*.json and npm install are still CACHED
# only "COPY . ." and later layers rebuild
# Round 3: add any dependency in package.json, then build
docker build -t lab2-app:1.2 .
# -> cache breaks at COPY package*.json; npm install runs again
```
**คำถามท้ายแล็บ**
1. ทำไม `COPY package*.json ./` ต้องมาก่อน `COPY . .`? → *เพื่อให้ layer `npm install` ใช้ cache ได้ตราบใดที่ dependencies ไม่เปลี่ยน — แก้โค้ดแล้ว build เร็วขึ้นมาก*
2. `EXPOSE 3000` เปิด port ให้เข้าถึงจากภายนอกหรือไม่? → *ไม่ — เป็นเอกสาร ต้อง `-p` ตอน run*
3. ค่า `hostname` ใน response คืออะไร? → *Container ID สั้น — พิสูจน์ว่าแอปรันใน environment ของตัวเอง*
---
## Lab 3: Multi-stage Build ด้วย Go (35 นาที)
**เป้าหมาย:** เห็นขนาด image ต่างกันเป็นสิบเท่า และเข้าใจว่าทำไม production image ไม่ควรมีเครื่องมือ build
สร้างโฟลเดอร์ `lab3/` และไฟล์:
```go
// main.go — minimal HTTP server in Go
package main
import (
"fmt"
"net/http"
"os"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
host, _ := os.Hostname()
fmt.Fprintf(w, "Hello from Go in Docker! host=%s\n", host)
})
fmt.Println("Listening on :8080")
http.ListenAndServe(":8080", nil)
}
```
```go
// go.mod
module lab3
go 1.24
```
**Dockerfile แบบที่ 1 — single-stage (แบบที่ไม่ควรทำ):**
```dockerfile
# Dockerfile.single
FROM golang:1.24
WORKDIR /src
COPY . .
RUN go build -o /app .
EXPOSE 8080
CMD ["/app"]
```
**Dockerfile แบบที่ 2 — multi-stage:**
```dockerfile
# Dockerfile
# ---------- Build stage ----------
FROM golang:1.24-alpine AS builder
WORKDIR /src
COPY go.mod ./
COPY . .
# Static binary: runs on any minimal base image
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app .
# ---------- Runtime stage ----------
FROM alpine:3.21
RUN addgroup -S app && adduser -S app -G app
COPY --from=builder /out/app /usr/local/bin/app
USER app
EXPOSE 8080
ENTRYPOINT ["app"]
```
**ขั้นตอน:**
```bash
cd lab3
docker build -f Dockerfile.single -t lab3:fat .
docker build -t lab3:slim .
docker images | grep lab3
# lab3 fat ... ~900MB+
# lab3 slim ... ~15MB <- ต่างกันประมาณ 50-60 เท่า
docker run -d --name go-slim -p 8082:8080 lab3:slim
curl http://localhost:8082
```
**ชวนสังเกตเพิ่ม:**
```bash
# The slim image has no Go toolchain and no source code inside
docker run --rm -it lab3:slim sh
/ $ which go # -> not found
/ $ ls /src # -> not found
```
**คำถามท้ายแล็บ**
1. ทำไม image `slim` ถึงปลอดภัยกว่านอกจากเรื่องขนาด? → *ไม่มี compiler/source/tooling ให้ผู้โจมตีใช้ต่อยอด — attack surface เล็กลงมาก*
2. `CGO_ENABLED=0` ใส่ไปทำไม? → *ให้ได้ static binary ที่ไม่พึ่ง libc ของระบบ จึงรันบน alpine/scratch ได้*
3. ถ้าเปลี่ยน stage สุดท้ายเป็น `FROM scratch` ต้องระวังอะไร? → *ไม่มี shell ให้ exec, ไม่มี CA certificates (ต้องก๊อปมาเองถ้าเรียก HTTPS), debug ยากขึ้น*
---
## Lab 4: Volume และความอยู่รอดของข้อมูล (30 นาที)
**เป้าหมาย:** พิสูจน์ว่า volume ทำให้ข้อมูลรอดข้ามชีวิตของ container
```bash
# 1) Postgres WITHOUT a volume
docker run -d --name db1 -e POSTGRES_PASSWORD=secret postgres:17-alpine
sleep 5
docker exec -it db1 psql -U postgres -c "CREATE TABLE t(msg TEXT); INSERT INTO t VALUES ('important data');"
docker exec -it db1 psql -U postgres -c "SELECT * FROM t;"
# Destroy and recreate
docker rm -f db1
docker run -d --name db1 -e POSTGRES_PASSWORD=secret postgres:17-alpine
sleep 5
docker exec -it db1 psql -U postgres -c "SELECT * FROM t;"
# -> ERROR: relation "t" does not exist (data is GONE)
# 2) Postgres WITH a named volume
docker volume create pgdata
docker rm -f db1
docker run -d --name db2 -e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data postgres:17-alpine
sleep 5
docker exec -it db2 psql -U postgres -c "CREATE TABLE t(msg TEXT); INSERT INTO t VALUES ('survives!');"
# Destroy and recreate with the SAME volume
docker rm -f db2
docker run -d --name db3 -e POSTGRES_PASSWORD=secret \
-v pgdata:/var/lib/postgresql/data postgres:17-alpine
sleep 5
docker exec -it db3 psql -U postgres -c "SELECT * FROM t;"
# -> survives! (data persisted in the volume)
# 3) Inspect and clean up
docker volume inspect pgdata
docker rm -f db3
docker volume rm pgdata
```
**คำถามท้ายแล็บ**
1. ตอนไหนกันแน่ที่ข้อมูล "หายจริง"? → *เมื่อลบ volume (`docker volume rm`) — การลบ container ไม่กระทบข้อมูลใน volume*
2. path `/var/lib/postgresql/data` รู้มาจากไหน? → *เอกสารของ image บน Docker Hub — ทุก image ระบุ path ข้อมูลของตัวเอง*
3. ถ้าใช้ bind mount `-v $(pwd)/pgdata:/var/lib/postgresql/data` ต่างจาก named volume อย่างไร? → *ข้อมูลไปอยู่ในโฟลเดอร์ที่เรากำหนดบน host เห็น/แก้ตรงได้ แต่ผูกกับ path เครื่องนั้นและมีประเด็น permission*
---
## Lab 5: Networking — คุยกันด้วยชื่อ (25 นาที)
**เป้าหมาย:** เข้าใจว่า user-defined network ให้ DNS และ `localhost` ใน container คือตัวมันเอง
```bash
# 1) Create a network and two containers on it
docker network create labnet
docker run -d --name red --network labnet nginx:1.27-alpine
docker run -d --name blue --network labnet alpine:3.21 sleep 3600
# 2) From "blue", call "red" BY NAME
docker exec -it blue sh
/ # wget -qO- http://red # nginx welcome page -> DNS by container name works
/ # ping -c 2 red
/ # exit
# 3) Show that the DEFAULT bridge cannot do this
docker run -d --name green nginx:1.27-alpine # no --network -> default bridge
docker exec -it blue wget -qO- --timeout=3 http://green
# -> fails: name "green" does not resolve on the default bridge
# 4) Show the classic localhost mistake
docker exec -it blue wget -qO- --timeout=3 http://localhost
# -> fails: localhost inside "blue" is "blue" itself, and blue runs no web server
# Clean up
docker rm -f red blue green
docker network rm labnet
```
**คำถามท้ายแล็บ**
1. อะไรคือความต่างสำคัญระหว่าง default bridge กับ network ที่สร้างเอง? → *Network ที่สร้างเองมี embedded DNS — เรียกกันด้วยชื่อ container/service ได้*
2. แอปใน container จะต่อ database ที่รันอีก container ควรตั้ง host เป็นอะไร? → *ชื่อ container/service (เช่น `db`) ไม่ใช่ `localhost`*
---
## Lab 6: Docker Compose — ยกทั้งระบบด้วยคำสั่งเดียว (45 นาที)
**เป้าหมาย:** ประกอบ API + Database + Web UI, ใช้ healthcheck/depends_on, เห็นผลของ `down` vs `down -v`
สร้างโฟลเดอร์ `lab6/` — ใช้แอปจาก Lab 2 (`server.js`, `package.json`, `Dockerfile`, `.dockerignore`) แล้วเพิ่ม 2 ไฟล์:
```yaml
# compose.yaml
services:
api:
build: .
ports:
- "3000:3000"
environment:
APP_VERSION: compose-1.0
DB_HOST: db # service name = DNS name
DB_PASSWORD: ${DB_PASSWORD}
depends_on:
db:
condition: service_healthy # wait for Postgres to be truly ready
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 10
adminer:
image: adminer:4
ports:
- "8081:8080"
depends_on:
- db
volumes:
db_data:
```
```
# .env
DB_PASSWORD=lab6-secret
```
**ขั้นตอน:**
```bash
cd lab6
docker compose up -d
docker compose ps # observe: db becomes (healthy), then api starts
docker compose logs -f api # Ctrl+C to exit
curl http://localhost:3000
# Open http://localhost:8081 -> Adminer: System=PostgreSQL, Server=db, User=postgres
# Create data in the db (via Adminer or psql), then:
docker compose down # containers & network removed, volume SURVIVES
docker compose up -d # data is still there
docker compose down -v # now the volume is deleted too
docker compose up -d # fresh empty database
docker compose down -v
```
**คำถามท้ายแล็บ**
1. ใน Adminer ทำไมช่อง Server ใส่ `db` ไม่ใช่ `localhost`? → *Adminer รันใน container ของตัวเอง — ต้องเรียก Postgres ผ่านชื่อ service บน network ของ Compose*
2. ถ้าเอา `healthcheck` ออกแต่คง `condition: service_healthy` ไว้ จะเกิดอะไร? → *Compose ฟ้อง error เพราะไม่มีข้อมูลสุขภาพให้รอ — สองอย่างนี้ต้องมาคู่กัน*
3. ทีมบอกว่า "สั่ง down แล้วข้อมูลลูกค้าหาย" — น่าจะเกิดจากคำสั่งไหน? → *`docker compose down -v` (หรือไม่ได้ประกาศ volume ตั้งแต่แรก)*
---
## Lab 7: ห้องซ่อม — วินิจฉัย Container ที่พัง (40 นาที)
**เป้าหมาย:** ฝึกไล่ปัญหาอย่างเป็นระบบด้วย `ps -a` → `logs` → `inspect` → `exec`
ให้ผู้เรียนรันทีละเคสแล้ววินิจฉัยเองก่อนดูเฉลย
```bash
# Case A
docker run -d --name caseA -p 80:80 nginx:1.27-alpine
docker run -d --name caseA2 -p 80:80 nginx:1.27-alpine
# Case B
docker run -d --name caseB alpine:3.21
# Case C
docker run -d --name caseC alpine:3.21 bash -c "echo hi"
# Case D
docker run -d --name caseD --memory 10m --memory-swap 10m postgres:17-alpine \
|| true; sleep 8; docker ps -a | grep caseD
# Case E
docker run -d --name caseE -e POSTGRES_PASSWORD= postgres:17-alpine
```
**แนวเฉลย**
| เคส | อาการ | การวินิจฉัย | สาเหตุ/บทเรียน |
|---|---|---|---|
| A | container ที่สองรันไม่ขึ้น | error ตอน run: `port is already allocated` | host port ซ้ำ — เปลี่ยน port ฝั่ง host |
| B | `Exited (0)` ทันที | `docker ps -a` เห็น exit 0; ไม่มี error ใน logs | image `alpine` คำสั่งปริยายคือ `sh` ซึ่งจบทันทีเมื่อไม่มี TTY — container อยู่ได้เท่าที่ PID 1 อยู่ |
| C | `Exited (127)` | `docker logs caseC` → `bash: not found` | alpine ไม่มี bash (มี `sh`) — exit 127 = command not found |
| D | `Exited (137)` | `docker inspect caseD --format '{{.State.OOMKilled}}'` → `true` | RAM limit 10m ต่ำเกิน — 137 = SIGKILL/OOMKilled |
| E | restart วน / Exited (1) | `docker logs caseE` → ข้อความบอกว่าต้องกำหนด password/ENV ให้ถูก | environment variable จำเป็นไม่ครบ — อ่าน error ของ image เป็นทางลัดที่สุด |
```bash
# Clean up
docker rm -f caseA caseA2 caseB caseC caseD caseE 2>/dev/null
```
---
---
# เคสตัวอย่างสำหรับชวนวิเคราะห์ (Case Discussions)
> ใช้ท้ายคาบหรือแทรกระหว่างหัวข้อ — โยนโจทย์ ให้คิดเป็นกลุ่ม 5–10 นาที แล้วเฉลยร่วมกัน
## Case 1: "มันรันได้บนเครื่องผม"
**สถานการณ์:** ทีมพัฒนาแอปด้วย Node 22 บนเครื่อง dev แต่ server test ติดตั้ง Node 18 ไว้ — deploy แล้วพังเพราะ syntax ใหม่ใช้ไม่ได้ ทีม ops ไม่กล้าอัปเกรด Node บน server เพราะมีแอปเก่าอีก 3 ตัวใช้อยู่
**คำถามชวนคิด:** Docker ช่วยแก้สถานการณ์นี้ตรงไหนบ้าง? แอปเก่า 3 ตัวต้องแก้อะไรไหม?
**แนวเฉลย:** แต่ละแอป pin base image ของตัวเอง (`node:22-alpine`, `node:18-alpine`) รันคู่กันบนเครื่องเดียวโดยไม่ตีกัน — server ไม่ต้องติดตั้ง Node เลย มีแค่ Docker · แอปเก่ายังไม่ต้องแตะทันที ค่อยๆ containerize ทีละตัวได้ · จุดสำคัญ: environment กลายเป็นสิ่งที่ระบุไว้ใน Dockerfile (reviewable, version-controlled) แทนที่จะเป็นสภาพของเครื่อง
## Case 2: Image อ้วน 1.2 GB
**สถานการณ์:** ทีมมี image แอป Python ขนาด 1.2 GB — pull ช้า, deploy ช้า, ค่า storage บาน ตรวจ Dockerfile พบ: `FROM python:3.12` (ตัวเต็ม), `COPY . .` ก่อน `pip install`, ไม่มี `.dockerignore` (มี `.git` 300 MB กับโฟลเดอร์ `data/` เข้าไปด้วย), ติดตั้ง `gcc` ไว้ compile library แล้วไม่ได้เอาออก
**คำถามชวนคิด:** ระบุปัญหาให้ครบและเรียงลำดับสิ่งที่จะแก้ก่อน-หลัง
**แนวเฉลย:** (1) เพิ่ม `.dockerignore` ตัด `.git`, `data/`, cache — ได้ผลทันทีและง่ายสุด (2) เปลี่ยน base เป็น `python:3.12-slim` (3) จัดลำดับ `COPY requirements.txt` → `pip install --no-cache-dir` → `COPY . .` เพื่อ cache (4) multi-stage: stage แรกมี gcc ไว้ build wheel, stage สุดท้าย copy เฉพาะ site-packages/venv — คาดหวังผลรวม: จาก 1.2 GB เหลือหลักร้อย MB ต้นๆ หรือต่ำกว่า
## Case 3: Secret หลุดเข้า Image
**สถานการณ์:** Dev ใช้ `COPY . .` โดยไม่มี `.dockerignore` ไฟล์ `.env` (มี DB password และ API key ของจริง) เข้าไปอยู่ใน image และถูก push ขึ้น registry ที่คนทั้งบริษัทเข้าถึงได้ — ภายหลัง dev แก้ด้วยการเพิ่ม `RUN rm /app/.env` ใน Dockerfile แล้ว push ทับ
**คำถามชวนคิด:** วิธี "แก้" ของ dev คนนี้ปิดรูรั่วได้จริงไหม? ขั้นตอนตอบสนองที่ถูกต้องคืออะไร?
**แนวเฉลย:** ไม่ได้ — `rm` สร้าง layer ใหม่ทับ แต่ไฟล์ยังอยู่ใน layer เดิม ดึงออกมาดูได้ (`docker history`, save แล้วแตก layer) · ขั้นตอนที่ถูก: **(1) rotate secret ทุกตัวทันที** — ถือว่ารั่วแล้ว (2) ลบ tag/image ออกจาก registry (3) แก้ต้นเหตุ: `.env` เข้า `.dockerignore`, inject secret ตอน runtime หรือใช้ BuildKit secret mount (4) เพิ่ม secret scanning ใน CI · บทเรียนหลัก: **layer คือประวัติศาสตร์ที่ลบไม่ได้ — กันไว้ก่อนเข้าเท่านั้น**
## Case 4: ข้อมูลหายหลังอัปเดตระบบ
**สถานการณ์:** ระบบภายในตัวหนึ่งรัน MariaDB ด้วย `docker run` เปล่าๆ ไม่มี `-v` ใช้งานมา 3 เดือน — วันหนึ่ง admin อัปเดต image ด้วยการ `docker rm -f` แล้ว run ใหม่ ข้อมูลทั้งหมดหายเกลี้ยง ไม่มี backup
**คำถามชวนคิด:** ข้อมูลไปไหน? กู้ได้ไหม? ออกแบบใหม่อย่างไรไม่ให้เกิดซ้ำ?
**แนวเฉลย:** ข้อมูลอยู่ใน writable layer ของ container ตัวเก่า — ถูกลบไปพร้อม `docker rm` โอกาสกู้แทบเป็นศูนย์ · ออกแบบใหม่: named volume สำหรับ data directory เสมอ, มี backup job (`docker exec ... mysqldump` เข้าที่เก็บภายนอก + ทดสอบ restore), เขียน runbook การอัปเดตที่ถูกต้อง (stop → backup → run ใหม่ด้วย volume เดิม) · ประเด็นเสริมที่ดี: การ "อัปเดต image" ที่ถูกคือสร้าง container ใหม่จาก image ใหม่ + volume เดิม — ไม่ใช่การพยายามอัปเดตในตัว container
## Case 5: เปิด Port Database สู่โลก
**สถานการณ์:** ทีมรัน MariaDB ด้วย `-p 3306:3306` บน VPS ที่มี public IP โดยคิดว่า "ตั้ง ufw ปิดไว้แล้ว" — ต่อมาได้รับแจ้งเตือนจากผู้ให้บริการว่า port 3306 เปิดสู่อินเทอร์เน็ต
**คำถามชวนคิด:** ทำไม ufw ถึงไม่ช่วย? ทางเลือกในการออกแบบมีอะไรบ้าง?
**แนวเฉลย:** Docker จัดการ iptables เอง (chain DOCKER) ซึ่งรับ traffic ก่อนกฎของ ufw จะทำงาน — ผลคือ `-p` เปิดจริงแม้ ufw บอกว่าปิด · ทางเลือกเรียงตามความเหมาะสม: (1) ไม่ publish เลย — ให้แอปคุยกับ DB ผ่าน user-defined network ภายใน (2) ถ้าจำเป็นต้องเข้าจากเครื่อง host เอง ใช้ `-p 127.0.0.1:3306:3306` (3) ถ้าต้องเข้าจากภายนอกจริง ใช้ VPN/SSH tunnel + จำกัด source IP + แข็งแรงเรื่อง auth/TLS · บทเรียน: **การ publish port ของ Docker คือการเปิดประตูจริง — ต้องออกแบบอย่างตั้งใจ ไม่ใช่ค่า default**
---
## ตารางเทียบแล็บกับหัวข้อในสไลด์
| แล็บ | ใช้หลังหัวข้อ | เวลา | แนวคิดหลัก |
|---|---|---|---|
| Lab 0–1 | คำสั่งพื้นฐาน | 35 นาที | lifecycle, port, logs, exec, ephemeral |
| Lab 2 | Dockerfile | 40 นาที | build, layer caching, .dockerignore |
| Lab 3 | Multi-stage | 35 นาที | ขนาด image, security ของ runtime image |
| Lab 4 | Volumes | 30 นาที | data persistence |
| Lab 5 | Networking | 25 นาที | user-defined network, DNS, localhost trap |
| Lab 6 | Compose | 45 นาที | multi-service, healthcheck, down vs down -v |
| Lab 7 | Troubleshooting | 40 นาที | exit codes, กระบวนการวินิจฉัย |