Product Management
Đăng nhập
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Bài 17 — Docker Compose — Multi-service local dev

Đã đọc 0/14 mục · còn khoảng 11 phút
Cách chúng tôi ghi nhận

Một mục được tính là đã đọc khi nó nằm giữa màn hình đủ lâu so với độ dài của nó (bảng và sơ đồ tính một nửa), bạn vẫn đang cuộn hoặc bấm phím, và bạn đã thấy tới cuối mục. Lướt nhanh hoặc để tab mở không được tính. Chúng tôi chỉ lưu thời gian đọc từng mục — không lưu gì khác.

Mở đầu — vì sao bài này quan trọng

Ở bài trước, bạn đã học cách viết một Dockerfile chuẩn để đóng gói một ứng dụng thành image. Nhưng trong thực tế, gần như không có ứng dụng nào chạy một mình. Một web app điển hình cần ít nhất: một container cho backend (PHP, Node, Python...), một container cho database (MySQL, PostgreSQL), thường thêm một Redis để cache, đôi khi có thêm nginx làm reverse proxy, một worker xử lý queue... Nếu phải chạy từng container bằng lệnh docker run thủ công, bạn sẽ nhanh chóng ngập trong một mớ tham số -p, -v, -e, --network dài ngoằng, và mỗi lần khởi động lại phải gõ đúng thứ tự, đúng tên network. Chỉ cần một đồng nghiệp mới vào team, việc dựng môi trường local có thể mất cả buổi sáng.

Docker Compose sinh ra chính để giải quyết nỗi đau này. Thay vì gõ hàng chục lệnh, bạn mô tả toàn bộ hệ thống nhiều container trong một file YAML duy nhất, rồi chỉ cần docker compose up là mọi thứ chạy đúng cấu hình, đúng thứ tự, đúng network. Đây là công cụ mà mọi DevOps Engineer đều dùng hằng ngày cho local development và cho các deployment nhỏ trên một máy chủ. Nắm vững Compose cũng là bước đệm tư duy trước khi bạn bước sang Kubernetes ở các bài sau — vì bản chất "khai báo trạng thái mong muốn của nhiều service" là giống nhau, chỉ khác về quy mô.

Khái niệm cốt lõi

Compose giải quyết vấn đề gì

Docker Compose là công cụ định nghĩa và chạy các ứng dụng multi-container. Triết lý của nó là declarative (khai báo): bạn nói "tôi muốn hệ thống trông như thế này", còn Compose lo phần dựng lên cho khớp — chứ bạn không phải ra lệnh từng bước như kiểu imperative (mệnh lệnh) của docker run.

Một điểm quan trọng cần làm rõ ngay: từ Docker phiên bản mới, lệnh chuẩn là docker compose (viết liền, dạng plugin, viết bằng Go), thay cho docker-compose (có gạch nối, bản cũ viết bằng Python). Chức năng gần như tương đương, nhưng bạn nên dùng docker compose cho các dự án mới.

Cấu trúc một file docker-compose.yml

File mặc định tên docker-compose.yml (hoặc compose.yaml), gồm vài khối chính:

  • services: danh sách các container. Mỗi service là một khối con, tên service chính là tên bạn tự đặt (ví dụ web, db, redis).
  • networks: định nghĩa các mạng ảo để service giao tiếp với nhau.
  • volumes: định nghĩa các vùng lưu trữ bền vững, không mất khi container bị xóa.
Hãy xem một ví dụ đầy đủ cho một web app PHP + MySQL + Redis:

services:
  web:
    build: .
    ports:
      - "8080:80"
    environment:
      - DB_HOST=db
      - DB_USER=app
      - DB_PASSWORD=secret
      - REDIS_HOST=redis
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - ./src:/var/www/html
    restart: unless-stopped

  db:
    image: mysql:8.0
    environment:
      - MYSQL_DATABASE=appdb
      - MYSQL_USER=app
      - MYSQL_PASSWORD=secret
      - MYSQL_ROOT_PASSWORD=rootsecret
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    restart: unless-stopped

volumes:
  db_data:

Những trường quan trọng nhất

  • build vs image: build: . bảo Compose tự build image từ Dockerfile trong thư mục hiện tại. image: mysql:8.0 bảo Compose kéo image có sẵn từ registry. Một service dùng một trong hai (hoặc cả hai nếu bạn muốn build rồi đặt tên image).
  • ports: ánh xạ cổng "host:container". "8080:80" nghĩa là truy cập localhost:8080 trên máy bạn sẽ vào cổng 80 trong container.
  • environment: truyền biến môi trường vào container — nơi đặt cấu hình như host database, mật khẩu, feature flag.
  • volumes: gắn dữ liệu. Có hai loại — bind mount (./src:/var/www/html, map thư mục thật trên máy vào container, cực hữu ích cho dev vì sửa code là thấy ngay) và named volume (db_data:/var/lib/mysql, do Docker quản lý, dùng để giữ dữ liệu database).
  • depends_on: thứ tự khởi động. Nhưng lưu ý cực kỳ quan trọng: mặc định depends_on chỉ đợi container khởi động, không đợi service sẵn sàng nhận kết nối. Muốn đợi thật sự, phải kết hợp với healthcheck và condition: service_healthy như ví dụ trên.

Service discovery — điểm ăn tiền của Compose

Đây là thứ khiến nhiều người mới bất ngờ: trong ví dụ trên, service web kết nối tới database bằng host tên là db — chính là tên service. Compose tự động tạo một mạng nội bộ và một DNS nội bộ, nên các service gọi nhau bằng tên service thay vì IP. Bạn không cần biết IP của container database là bao nhiêu; cứ dùng db:3306 là được. Đây là nền tảng tư duy sẽ theo bạn suốt sự nghiệp, kể cả khi lên Kubernetes.

Tình huống thực tế

Ví dụ 1 — Startup fintech ở TP.HCM và câu chuyện onboarding

Một startup fintech khoảng 15 kỹ sư ở Quận 1, TP.HCM, có sản phẩm gồm backend Laravel, database MySQL, Redis cho session và một service worker gửi thông báo. Trước khi dùng Compose, mỗi lập trình viên mới mất trung bình nửa ngày đến một ngày để dựng môi trường local: cài đúng phiên bản PHP, cài MySQL, config Redis, rồi loay hoay vì "máy tôi chạy được mà máy bạn thì lỗi" — kinh điển đến mức cả team gọi đùa là "hội chứng máy tôi".

Team lead viết một file docker-compose.yml gói toàn bộ 4 service, commit vào repo. Từ đó, quy trình onboarding gọn lại thành ba lệnh: git clone, docker compose up, và mở localhost:8080. Thời gian dựng môi trường tụt từ gần một ngày xuống khoảng 10 phút (chủ yếu là chờ tải image lần đầu). Quan trọng hơn, mọi người dùng chính xác cùng phiên bản MySQL 8.0 và Redis 7, xóa sổ hoàn toàn lỗi lệch phiên bản.

Bài học rút ra: giá trị lớn nhất của Compose trong giai đoạn đầu không phải là "chạy nhanh" mà là tính lặp lại và đồng nhất môi trường giữa các thành viên. File Compose trở thành tài liệu sống mô tả kiến trúc hệ thống.

Ví dụ 2 — Blog thương mại điện tử triển khai nhỏ trên một VPS Hostinger

Một chủ shop bán mỹ phẩm online tự vận hành một website WordPress kèm một dịch vụ phân tích đơn hàng viết bằng Node.js, host trên một VPS Hostinger giá khoảng 300.000đ/tháng. Họ không cần Kubernetes — quá thừa thãi cho quy mô vài nghìn lượt truy cập mỗi ngày. Họ dùng Compose để chạy 3 service trên đúng một máy: WordPress, MySQL, và service Node.

services:
  wordpress:
    image: wordpress:6.4
    ports:
      - "80:80"
    environment:
      - WORDPRESS_DB_HOST=db
      - WORDPRESS_DB_USER=wp
      - WORDPRESS_DB_PASSWORD=${WP_DB_PASSWORD}
    depends_on:
      - db
    restart: always

  db:
    image: mysql:8.0
    environment:
      - MYSQL_DATABASE=wordpress
      - MYSQL_USER=wp
      - MYSQL_PASSWORD=${WP_DB_PASSWORD}
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
    volumes:
      - db_data:/var/lib/mysql
    restart: always

volumes:
  db_data:

Chú ý cú pháp ${WP_DB_PASSWORD} — mật khẩu được đọc từ file .env đặt cạnh file Compose, không hardcode vào YAML. File .env bị đưa vào .gitignore nên không bao giờ bị lộ khi push code.

Bài học rút ra: Compose không chỉ dành cho local dev. Với các deployment nhỏ trên một máy chủ, thêm restart: always và tách secret ra .env, nó là một giải pháp production hoàn toàn hợp lý, tiết kiệm và dễ bảo trì. Đừng vội nhảy lên Kubernetes chỉ vì nghe "sang".

Ví dụ 3 — Team backend gặp lỗi "connection refused" lúc khởi động

Một team ở Đà Nẵng làm hệ thống đặt vé xe khách gặp lỗi khó chịu: mỗi lần chạy docker compose up, container backend đôi khi crash ngay với lỗi Connection refused tới MySQL. Chạy lại lần hai thì lại bình thường. Hiện tượng "lúc được lúc không" khiến họ mất gần hai ngày điều tra.

Nguyên nhân: họ chỉ khai báo depends_on: - db. Như đã nói ở trên, depends_on chỉ đảm bảo container MySQL được khởi động trước, nhưng MySQL cần thêm vài giây để khởi tạo và sẵn sàng nhận kết nối. Backend khởi động quá nhanh, kết nối khi MySQL chưa sẵn sàng, và crash. Giải pháp là thêm healthcheck vào service db và dùng condition: service_healthy như ví dụ ở phần khái niệm.

Bài học rút ra: "khởi động xong" khác với "sẵn sàng phục vụ". Đây là một trong những cạm bẫy phổ biến nhất với người mới dùng Compose, và tư duy healthcheck này còn theo bạn mãi tới readiness probe của Kubernetes.

Hướng dẫn từng bước

Giả sử bạn muốn dựng một stack đơn giản gồm một app Node và một Redis. Làm theo các bước sau:

Bước 1 — Chuẩn bị thư mục dự án. Tạo một thư mục, bên trong có mã nguồn app và một Dockerfile để build image cho app của bạn (đã học ở bài trước).

Bước 2 — Tạo file docker-compose.yml ở thư mục gốc dự án với nội dung mô tả các service, ví dụ một service app dùng build: . và một service redis dùng image: redis:7-alpine.

Bước 3 — Tách cấu hình nhạy cảm ra .env. Tạo file .env chứa các biến như mật khẩu, và tham chiếu chúng trong YAML bằng cú pháp ${TÊN_BIẾN}. Nhớ thêm .env vào .gitignore.

Bước 4 — Khởi động hệ thống bằng lệnh docker compose up -d. Cờ -d (detached) cho phép chạy nền, trả lại terminal cho bạn. Lần đầu Compose sẽ build image và tải các image cần thiết.

Bước 5 — Kiểm tra trạng thái bằng docker compose ps để xem các container đang chạy hay không, và docker compose logs -f app để xem log real-time của service app. Cờ -f là follow, giống tail -f.

Bước 6 — Khi sửa code hoặc sửa Dockerfile, build lại bằng docker compose up -d --build để Compose build lại image rồi khởi động lại đúng các service thay đổi.

Bước 7 — Dọn dẹp. docker compose down để dừng và xóa các container cùng network. Nếu muốn xóa cả dữ liệu trong named volume, thêm cờ -v: docker compose down -v (cẩn thận, lệnh này xóa database).

Một vài lệnh hữu ích khác trong công việc hằng ngày: docker compose exec app sh để mở shell vào trong container app đang chạy (debug); docker compose restart web để khởi động lại một service; docker compose pull để tải phiên bản mới nhất của các image.

Lỗi thường gặp & mẹo

  • Nhầm depends_on với "đợi sẵn sàng". Như ví dụ 3, luôn dùng healthcheck + condition: service_healthy cho các dependency là database hay message broker. Đây là lỗi số một.
  • Hardcode mật khẩu trong YAML rồi push lên Git. Luôn dùng file .env và đưa nó vào .gitignore. Rất nhiều vụ lộ credential đến từ sự cẩu thả này.
  • Quên named volume cho database. Nếu không khai báo volume cho /var/lib/mysql, mỗi lần docker compose down là mất sạch dữ liệu. Với dev thì phiền, với production thì thảm họa.
  • Lẫn lộn thứ tự cổng host:container. "8080:80" — số bên trái là cổng trên máy bạn, số bên phải là cổng trong container. Đảo ngược là không truy cập được.
  • Xung đột cổng. Nếu máy bạn đã có dịch vụ chiếm cổng 80, Compose sẽ báo lỗi "port is already allocated". Đổi cổng host (ví dụ "8081:80") hoặc tắt dịch vụ kia.
  • Sửa code mà không thấy thay đổi. Nếu bạn build image chứa code (không bind mount), phải --build lại. Trong dev, dùng bind mount ./src:/app để sửa là thấy ngay không cần build.
  • Mẹo — tách file cho từng môi trường. Bạn có thể dùng docker-compose.override.yml cho cấu hình dev (Compose tự merge), giữ file gốc sạch cho cấu hình chung. Đây là pattern rất thông dụng.
  • Mẹo — kiểm tra cú pháp trước. Chạy docker compose config để Compose in ra cấu hình đã resolve (đã thay biến .env), giúp bạn phát hiện lỗi YAML hay biến thiếu trước khi up.

Bài tập thực hành

  • Dựng stack cơ bản. Viết một docker-compose.yml gồm hai service: một web app bất kỳ (có thể dùng image nginx cho nhanh) map ra cổng 8080, và một Redis. Chạy docker compose up -d, kiểm tra bằng docker compose ps, mở trình duyệt xác nhận nginx chạy.
  • Thêm database có healthcheck. Bổ sung service MySQL với named volume và một healthcheck dùng mysqladmin ping. Cho service web depends_on MySQL với condition: service_healthy. Chạy docker compose up và quan sát thứ tự khởi động trong log.
  • Tách secret. Chuyển toàn bộ mật khẩu sang file .env, tham chiếu bằng ${...} trong YAML. Chạy docker compose config để xác nhận biến đã được thay đúng, và thêm .env vào .gitignore.
  • Kiểm chứng tính bền vững của dữ liệu. Tạo một bảng dữ liệu trong MySQL, chạy docker compose down (không có -v), rồi up lại. Xác nhận dữ liệu vẫn còn. Sau đó thử docker compose down -v và quan sát dữ liệu bị mất — để hiểu rõ vai trò của named volume.

Tóm tắt

Docker Compose cho phép bạn mô tả toàn bộ một hệ thống nhiều container trong một file YAML khai báo, rồi dựng lên chỉ với docker compose up. Bốn khối cần nắm là services, networks, volumes, và các trường quan trọng gồm build/image, ports, environment, volumes, depends_on, healthcheck. Điểm mạnh cốt lõi là service discovery bằng tên — các service gọi nhau bằng tên thay vì IP — và khả năng tái lập môi trường đồng nhất giữa mọi thành viên trong team.

Compose tỏa sáng ở hai bối cảnh: local development (onboarding nhanh, môi trường thống nhất) và các deployment nhỏ trên một máy chủ (kèm restart: always và secret trong .env). Hãy khắc cốt ghi tâm cạm bẫy depends_on không đợi service sẵn sàng, và luôn tách secret ra khỏi YAML. Khi đã thành thạo tư duy "khai báo trạng thái mong muốn của nhiều service" ở đây, bạn đã có nền tảng vững để bước sang Kubernetes — nơi cũng chính triết lý đó nhưng ở quy mô cluster.

Học xong bài này rồi? Tạo tài khoản miễn phí để lưu lại — lần sau vào là biết ngay đang dở ở đâu, và học hết khóa thì có chứng chỉ. Lưu tiến độ của tôi