Dockerize Development với Javascript MERN Stack

Việc cài đặt môi trường để phát triển sử dụng MERN Stack cũng là một vấn đề nếu bạn không quen sử dụng Linux, Mac, hoặc do đang xài Windows (cài tools trên Windows chuối bm). Tui sẽ giới thiệu với các bạn một cách sử dụng Docker và Docker-Compose để thiết lập môi trường lập trình với MERN Stack mà có thể chạy tốt trên Windows, Mac hay Linux, đúng với tinh thần lười của anh em dev, install one and develop anywhere.

Docker

Vì sao phải Dockerize?

Đầu tiên cùng tưởng tượng rằng bạn và đồng đội của mình đang cùng làm việc trên cùng một project. Một ngày đẹp trời đồng đội của bạn bỗng nhiên chạy source code để debug nhưng kết quả lại khác với kết quả trên máy của bạn khi chạy với cùng dữ liệu như nhau.

Có thể có một số nguyên nhân ví dụ như hệ điều hành của bạn và đồng nghiệp không giống nhau, verison của library trên máy bạn và trên máy của đồng nghiệp khác nhau, etc.

Docker sinh ra để giải quyết những vấn đề giống giống vậy. Hiểu một cách đơn giản, Docker container có thể được coi như là một cái “máy tính” nằm bên trong cái máy tính của bạn. Điều hay ho là bạn có thể gửi cái “máy tính” đó cho bạn bè, đồng đội của mình để họ có thể bật nó lên và chạy code mà kết quả sẽ giống y hệt với kết quả mà bạn thấy trên máy của mình.

Ngoài ra, Docker còn giúp thành viên tham gia trong project có thể khởi tạo môi trường để code trong vòng một nhốt nhạc mà không cần quan tâm tới docs và cài đặt, chỉ cần chạy duy nhất 1 lệnh.

docker-compose up

Yêu cầu kiến thức (search từ khoá Google nhé)

  • Git
  • Docker: “Đùa nghịch” với Docker trong 5 phút
  • Docker-compose: Cơ bản dùng để trigger chạy nhiều Dockerfile cùng một lúc và hơn thế nữa
  • Javascript với Node.js và React, npm, yarn
  • Một vài lệnh hay dùng trong terminal/bash

Vấn đề

Do xử dụng MERN Stack để code, do đó ta cần 3 thành phần là Node.js Express (api), React (webapp) và MySQL (dbms) để có thể chạy full-flow code. Trong đó phần MySQL sẽ không có sourcecode, do đó mình chỉ dùng git để lưu sourcecode của api và webapp, các thành phần còn lại như mysql và dependency của Node.js mình sẽ để trong .gitignore.

Source Code của React và Node.js tất nhiên phải để ở 2 thư mục repo khác nhau, tuy nhiên để dễ trigger chạy bằng docker-compose tui sẽ để 2 repo này trong 1 repo lớn (gọi là monorepo).

Xong bắt đầu tạo repo cho từng mục và viết Dockerfile cho thành phần đó thôi.

Node.js

Ở đây tui chỉ code một đoạn server express cơ bản để làm mẫu, xài Express làm server/routing, Sequelize làm ORM, Morgan cho phần logger, nhìn chung một server API cần cơ bản vậy là được.

Repo sẽ có đường dẫn/context path là ./server trong monorepo này.

const express = require("express")
const app = express()
const port = 3000
const morgan = require("morgan")
const sequelize = require("./db")

app.use(morgan("combined"))

app.get("/", (_, res) => {
  res.send("Hello world!")
})

// Some other routing and handler...

sequelize
  .authenticate()
  .then(() => {
    console.log("Connection has been established successfully.")
  })
  .catch(err => {
    console.error("Unable to connect to the database:", err)
  })

app.listen(port, () => console.info(`Example server listening on port ${port}`))

Nhớ chú ý tách phần source code ra thành thư mục src để lát sau viết Dockerfile dễ làm động nhé. Những phần còn lại như node_modules và package.json thì không cần động.

Ngoài ra, để hỗ trợ cho việc dev thì tui xài thêm nodemon cho việc hot reload mỗi lần sửa code, thay vì phải restart lại container.

"scripts": {
    "start": "node src/index.js",
    "dev": "nodemon src/index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

Dockerfile đơn giản như sau:

// Dùng image alpine cho nhẹ có (80MB), thay vì cái image gốc tận 800MB
// Bù vào đó, alpine khá nhẹ nên những thành phần như git, ssh không có, nếu muốn xài phải tự cài thêm
FROM node:10.15.3-alpine as builder

// Như yarn chẳng hạn, phải tự cài thêm
RUN apk add yarn

// Tạo thư mục chứa code ở /root/src/api, đường dẫn này thì thích để sao cũng được nhé
RUN mkdir -p /root/src/api
// Workdir để thiết lập context khi gọi lệnh
WORKDIR /root/src/api
// Khi xài nodemon để run thì phải thiết lập PATH
ENV PATH /root/src/api/node_modules/.bin:$PATH

// Copy từ context của Dockerfile vào context của Docker
COPY . .

// Cài dependency
RUN yarn

// Mở cổng 3000
EXPOSE 3000

// Khi run image thì sẽ chạy npm run dev
// Ở đây mình thiết lập script dev là sẽ chạy nodemon
ENTRYPOINT ["npm","run","dev"]

Còn phần map volume data nữa, phần này sẽ viết ở docker-compose.yml đặt ở root context của monorepo.

Cơ bản này Dockerfile này đã có thể buildrun rồi.

React

Repo sẽ có đường dẫn/context path là ./webapp trong monorepo này.

React thì init project đơn giản hơn do có create-react-app script. Cơ bản thì file index.js sẽ như sau:

import React from "react"
import ReactDOM from "react-dom"

ReactDOM.render(<div>Siple demo, hehe.</div>, document.getElementById("root"))

Các thành phần khác như Webpack, Babel đã có create-react-app lo, do đó khi run dev chỉ cần chạy create-react-scripts đã được định nghĩa trong file package.json, do port 3000 đã được dùng để chạy API nên react mình sẽ chạy ở port 3001:

"scripts": {
    "start": "PORT=3001 react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject"
},

Dockerfile của React cũng tương tự như bên của API, vì cùng xài Node.js để chạy:

FROM node:10.15.3-alpine as builder

RUN apk add yarn

RUN mkdir -p /root/src/app
WORKDIR /root/src/app
ENV PATH /root/src/app/node_modules/.bin:$PATH

COPY . .

RUN yarn

EXPOSE 3001

ENTRYPOINT ["npm","run","start"]

Docker-Compose

Docker-compose

Sẽ xử phần mapping volume với MySQL ở docker-compose luôn, do MySQL đã có image sẵn đủ xài rồi nên mình sẽ không cần viết Dockerfile nữa.

Phần dữ liệu của MySQL, được mapping ra ngoài để không bị mất sau khi stop container, sẽ được mapping ở context ./mysql, phần này nên để trong .gitignore để khỏi phải push lên Git.

version: "3"
services:
  api:
    build:
      context: ./server
    links:
      - database:database
    expose:
      - 3000
    ports:
      - 3000:3000
    image: server-dev
    container_name: server-dev
    volumes:
      - ./server/src:/root/src/api/src
    tty: true
  web:
    build:
      context: ./webapp
    expose:
      - 3001
    ports:
      - 3001:3001
    image: web-dev
    container_name: web-dev
    volumes:
      - ./webapp/src:/root/src/app/src
      - ./webapp/public:/root/src/app/public
    tty: true
  database:
    image: mysql:5.7
    container_name: db-dev
    restart: always
    environment:
      MYSQL_DATABASE: "example_dbname"
      MYSQL_USER: "username"
      MYSQL_PASSWORD: "password"
      MYSQL_ROOT_PASSWORD: "password"
    expose:
      - 3306
    ports:
      - 3306:3306
    volumes:
      - ./mysql:/var/lib/mysql

Để ý trong phần định nghĩa service api, do api có kết nối vào database mysql nên cần định nghĩa thêm phần links để container của api có thể gọi được qua container mysql, lúc này trong connect string, hostname sẽ là database (như định nghĩa trong phần links) thay vì là localhost.

Phần mapping volume, ta chỉ mapping phần sourcecode, những phần như .git và node_modules thì không cần map, do nó đã có sẵn trong image.

Cuối cùng ta có một monorepo hoàn chỉnh để team member có thể pull về và chạy docker-compose up là lên.

Mỗi lần add thêm dependency, có thể vào sửa file package.json, sau đó build lại image mới để add dependecy đó vào docker image: docker-compose build.

Docker

Link repo Github để xem kĩ hơn: Dockerize MERN, nhớ star github nếu thấy hay nha emoji-smiley

Dockerfile cho các stack khác như Vue/Angular cũng khá tương tự vì đều based trên môi trường Node.js.

Ngoài ra còn nhớ để ý viết .gitignore.dockerignore để ignore những thư mục như data của MySQL, dependency của Node.js hay thư mục build của React, thứ duy nhất nên ở trong git là phần source code nha.

Docker family