สวัสดีครับ บทความนี้จะมาเล่าถึงประสบการณ์การทำ Web Server เอง สด ๆ ร้อน เพราะบทความนี้จะเป็นบทความแรกของบล็อกนี้ ที่เล่าขั้นตอนทุกอย่างเกี่ยวกับการทำให้ Server นี้สามารถ Run เว็บที่คุณเห็นได้ในขณะนี้ จากประสบการณ์ที่ใช้ Shared Hosting ใช้ของสำเร็จรูปพวก Control Panel ต่าง ๆ มาตลอด พอมาวันนี้ต้องมานั่งทำ Server เอง มันโหดหินแค่ไหนสำหรับผม ผมจะมาเล่าให้ฟังครับ

ทำไมอยาก มีเว็บเป็นของตัวเอง

เริ่มต้นจากการที่ผมเองอยากมีชื่อโดเมนที่เป็นของตัวเองตั้งแต่สมัยผมยังอยู่ ม. 5 เหตุผลหลักคือผมต้องสมัครเข้าเรียนในสายคอมพิวเตอร์ในมหาวิทยาลัยสักที่หนึ่ง แต่ผมไม่มีผลงานเกี่ยวกับคอมพิวเตอร์เป็นหลักเป็นแหล่งเลย ผมจึงเลยมีความคิดว่า ผมต้องสร้างเว็บที่เล่าเกี่ยวกับตัวเอง รวมทั้งผลงานที่ผ่านมาในมัธยมศึกษาทั้งหมด เพื่อแสดงให้เห็นว่า ผมเรียนที่คณะนี้ได้นะ แม้เกรดผมไม่ดี แต่ผมมีความสามารถนะ และนี่จึงเป็นจุดเริ่มต้นนึงในการที่เริ่มอยากมีเว็บเป็นของตัวเอง

ผมจึงได้ปรึกษาน้องคนนึงที่รู้จักกันในค่าย Comcamp #27 ของ มจธ. ว่าอยากมีเว็บอ่ะ ต้องทำไงดี น้องคนนั้นก็เลยแนะนำให้ไปใช้ Shared Hosting ที่หนึ่ง ซึ่งผมก็ได้รู้จักเจ้า Control Panel อย่าง DirectAdmin ซึ่งมันก็ทำให้ผมมีชีวิตสะดวกสบายระดับนึงแหละ แต่ข้อจำกัดก็คือ ผมจะอัปเกรดอะไรก็ไม่ได้ ทำได้แค่อัปโหลดเว็บ แก้ Config นิดหน่อย ปรับอะไรได้ก็ไม่มาก

จนน้องคนนั้นก็มี Shared Hosting เป็นของตัวเอง เขาก็ให้มาใช้แบบฟรี ๆ และผมก็ได้รู้จัก Control Panel อีกเจ้า อย่าง Plesk ซึ่งมีความ Powerful ในตัวมันเอง จะ Build อะไรก็ได้ ทั้ง PHP NodeJS Ruby JSP คือแทบจะครอบคลุมการทำ Modern Website ในสมัยนั้น

ไป ๆ มากผมก็เริ่มคิดและว่า ถ้าวันนึงน้องไม่ให้ใช้ฟรีต้องทำไงต่ออ่ะ เราไม่อยากพึ่งพาใครแล้วอ่ะ เราอยากมี Server อ่ะ เราต้องทำไง

ผมอยู่ที่คณะเทคโนโลยีสารสนเทศ มจธ. ผมก็ได้รู้จักเพื่อนที่ชื่อ ปุรีวัฒน์ แก้วโป๋ย (ปุ๊) และ พรเลิศ หล่อเจริญรัตน์ (เกม) ทำให้ผมได้โลดแล่นอยู่บน Cloud Server มามากมาย ไม่ว่าจะเป็น Amazon Web Service, Google Cloud หรือแม้กระทั่ง Cloud Server ถูก ๆ เจ้าใหญ่ ๆ ของไทยอย่าง Bangmod Cloud ก็ทำให้ผมได้มีเว็บไซต์บน Cloud Server เช่นทุกวันนี้

อันนี้ต้องให้เครดิตเพื่อนผมเลยนะ ไม่งั้นเจอปัญหา Config เซิร์ฟแล้ว Error ผมก็ไปไม่เป็นเหมือนกันนะ มีเพื่อนดีก็งี้แหละ 555555+

สถาปัตยกรรม Web Server

จากการลองผิดลองถูกมาร่วม 6 ปี ทำให้ผมรู้ว่า

  1. การใช้ Shared Hosting ทำให้เราไม่สามารถขยับขยายที่ของตัวเองได้ หรือจะเล่นเทคโนโลยีใหม่ ๆ ก็ไม่สามารถทำได้สะดวกนัก ขอแอดมิน เดี๋ยวได้บ้าง ไม่ได้บ้าง
  2. การใช้ Control Panel ทำให้พอเวลาเกิดปัญหามักจะแก้ได้ยาก เพราะพอเวลาติดตั้งก็มักจะต้องติดตั้งลงไปใน Clean Server เลย พอเกิดปัญหาแก้ไม่ได้ ทางออกสุดท้ายคือทุบเซิร์ฟเวอร์ทิ้งอย่างเดียว

และนี่จึงเป็นสถาปัตยกรรมดังรูปครับ

สถาปัตยกรรมในการทำ Web Server
สถาปัตยกรรมของเว็บเซิร์ฟเวอร์ที่ออกแบบไว้

จากรูปจะเห็นว่า

  • ผมมีเซิร์ฟเวอร์บน Cloud เครื่องเดียวนะรันบน CentOS ซึ่งเป็น OS ที่ทรงประสิทธิภาพที่สุดตอนนี้
  • แต่ผมมี Virtual Machine ที่สามารถสร้างเป็น Service ต่าง ๆ ไม่ว่าจะเป็น WordPress, phpMyAdmin, แอป Laravel หรือแม้กระทั่ง Database Server, Storage Server
  • ผมจะหุ้ม Cloudflare อีกชั้นเพื่อกัน DDoS ละก็เหตุผลด้าน Security หลาย ๆ อย่าง

วิธีการเป็นขั้นเป็นตอน

เริ่มต้นผมจะหา Cloud Server สักเจ้า ผมได้ Bangmod Cloud นี่ละที่ถูกที่สุด ณ ตอนนี้ โดยผมจะใช้ CentOS เป็นเป็น OS ที่ใครหลายคนบอกว่าเป็น OS ที่เจ๋งที่สุดแล้ว

จากนั้นผมจะติดตั้ง Docker ตามขั้นตอนในเว็บไซต์ที่นี่ ละก็จะใช้ Docker-Compose เพื่อสร้าง Script เพื่อ Run Service ต่าง ๆ โดยติดตั้งตามขั้นตอนที่นี่

เสร็จแล้วผมจะจัดเรียง Structure ของโฟลเดอร์ต่าง ๆ เอาไว้ตามนี้ครับ

|-- .docker/
|  `-- certs/
|      |-- example.com.crt 
|      `-- example.com.key
|-- minio/
|   `-- volumes/
|       `-- storage/
|           `-- /* พื้นที่ Mount Volume */
|-- nginx/
|    `-- conf.d/
|        `-- nginx.conf
|-- web_dbadmin/
|   |-- conf.d/
|   |   `-- nginx.conf
|   |-- php/
|   |   `-- php.ini
|   |-- www/
|   |   `-- /* วางไฟล์เว็บไว้ที่นี่ */
|   `-- Dockerfile
|-- web_index/
|   |-- conf.d/
|   |   `-- nginx.conf
|   |-- php/
|   |   `-- php.ini
|   |-- www/
|   |   `-- /* วางไฟล์เว็บไว้ที่นี่ */
|   `-- Dockerfile
`-- web_laravel/
|   |-- conf.d/
|   |   `-- nginx.conf
|   |-- php/
|   |   `-- php.ini
|   |-- www/
|   |   `-- /* วางไฟล์เว็บไว้ที่นี่ */
|   `-- Dockerfile
`-- docker-compose.yml

จากนั้นผมจะเขียน docker-compose.yml ตามนี้ครับ

# docker-compose.yml
version: "3.7"
services:
  web_index:
    image: nginx:1.17-alpine
    container_name: ${WEB_INDEX_CONTAINER_NAME}
    depends_on:
      - php_index
      - mariadb
    volumes:
      - ./web_index/www:/usr/share/nginx/html
      - ./web_index/conf.d/nginx.conf:/etc/nginx/conf.d/default.conf
    networks:
      - web
    expose:
      - "80"
  php_index:
    container_name: ${PHP_INDEX_CONTAINER_NAME}
    image: example-com/php_index
    build:
      context: './web_index'
      dockerfile: 'Dockerfile'
    volumes:
      - ./web_index/php/php.ini:/usr/local/etc/php/conf.d/php_custom.ini
      - ./web_index/www:/usr/share/nginx/html
      - ./web_index/volumes/php_log:/var/log/php
    networks:
      - web
    restart: "always"
  web_dbadmin:
    image: nginx:1.17-alpine
    container_name: ${WEB_DBADMIN_CONTAINER_NAME}
    depends_on:
      - php_index
      - mariadb
    volumes:
      - ./web_dbadmin/www:/usr/share/nginx/html
      - ./web_dbadmin/conf.d/nginx.conf:/etc/nginx/conf.d/default.conf
    networks:
      - web
    expose:
      - 80
  php_dbadmin:
    container_name: ${PHP_DBADMIN_CONTAINER_NAME}
    image: example-com/php_dbadmin
    build:
      context: './web_dbadmin'
      dockerfile: 'Dockerfile'
    volumes:
      - ./web_dbadmin/php/php.ini:/usr/local/etc/php/conf.d/php_custom.ini
      - ./web_dbadmin/www:/usr/share/nginx/html
    networks:
      - web
    restart: "always"
  web_laravel:
    image: nginx:1.17-alpine
    container_name: ${WEB_LARAVEL_CONTAINER_NAME}
    depends_on:
      - php_index
      - mariadb
    volumes:
      - ./web_laravel/www:/usr/share/nginx/html
      - ./web_laravel/conf.d/nginx.conf:/etc/nginx/conf.d/default.conf
    networks:
      - web
    expose:
      - 80
  php_laravel:
    container_name: ${PHP_LARAVEL_CONTAINER_NAME}
    image: example-com/php_laravel
    build:
      context: './web_laravel'
      dockerfile: 'Dockerfile'
    volumes:
      - ./web_laravel/php/php.ini:/usr/local/etc/php/conf.d/php_custom.ini
      - ./web_laravel/www:/usr/share/nginx/html
    networks:
      - web
    restart: "always"
  nginx:
   image: nginx:1.17-alpine
    container_name: ${NGINX_CONTAINER_NAME}
    depends_on:
      - web_index
      - web_dbadmin
      - web_laravel
      - minio
    volumes:
      - ./nginx/conf.d/nginx.conf:/etc/nginx/conf.d/default.conf
      - ./.docker/certs/:/etc/nginx/certs:ro
    networks:
      - web
    ports:
      - ${NGINX_CONTAINER_PORT_HTTPS}:443
    restart: "always"
  mariadb:
    image: mariadb:10.4
    container_name: ${MARIADB_CONTAINER_NAME}
    command: ['mysqld', '--character-set-server=utf8mb4', '--collation-server=${MARIADB_CONTAINER_COLLATION}']
    networks:
      - mariadb
    ports:
      - ${MARIADB_CONTAINER_PORT}:3306
    restart: "always"
    volumes:
      - mariadb:/var/lib/mysql/
  minio:
    image: minio/minio
    container_name: ${MINIO_CONTAINER_NAME}
    environment:
      - MINIO_ACCESS_KEY=${MINIO_CONTAINER_KEY}
      - MINIO_SECRET_KEY=${MINIO_CONTAINER_SECRET}
      - MINIO_DOMAIN=${MINIO_CONTAINER_DOMAIN}
    command: [server, /home/root/minio]
    volumes:
      - ./minio/volumes/storage:/home/root/minio
    networks:
     - minio
      - web
    expose:
      - "80"
      - "9000"
    restart: "always"
networks:
  web:
    name: ${WEB_NETWORK_NAME}
  minio:
    name: ${MINIO_NETWORK_NAME}
  mariadb:
    name: ${MARIADB_NETWORK_NAME}
volumes:
  mariadb:
    name: ${MARIADB_VOLUME_NAME}

หลักการทำงานที่ผมจะทำก็คือ

  • MariaDB และ Minio ผมจะแยกเป็น 2 Service ปกติ
  • ผมจะมี Service หลักชื่อ Nginx ซึ่งเอาไว้เป็น Reverse Proxy เพื่อใช้ Serve หน้าเว็บจาก Service ลูกอีกทีผ่านพอร์ต 443
  • แต่ละ Web Service จะรันด้วย Nginx ด้วยพอร์ต 80
  • ถ้า Service ไหนจำเป็นต้อง Serve PHP ก็สร้าง Service PHP-FPM แยกต่างหาก

จากนั้นผมจะเขียน Dockerfile ของแต่ละ Service ดังนี้ครับ

# web_index/Dockerfile
FROM php:7.3-fpm-alpine3.10
RUN apk update && apk add \
    autoconf \
    dpkg-dev \
    dpkg \
    file \
    g++ \
    gcc \
    libc-dev \
    make \
    re2c \
    libpng-dev \
    imagemagick-dev
RUN docker-php-ext-install \
    mysqli \
    gd \
    bcmath
RUN printf "\n" | pecl install imagick && \
    docker-php-ext-enable imagick
# web_dbadmin/Dockerfile
FROM php:7.3-fpm-alpine3.10
RUN apk update && apk add \
    autoconf \
    dpkg-dev \
    dpkg \
    file \
    g++ \
    gcc \
    libc-dev \
    make \
    re2c
RUN docker-php-ext-install \
    mysqli
# web_laravel/Dockerfile
FROM php:7.3-fpm-alpine3.10
RUN apk update && apk add \
    git \
    libzip-dev \
    zip \
    unzip \
    autoconf \
    dpkg-dev \
    dpkg \
    file \
    g++ \
    gcc \
    libc-dev \
    make \
    re2c \
    openldap-dev \
    npm \
    yarn
RUN docker-php-ext-configure zip --with-libzip \
    && docker-php-ext-configure ldap --with-libdir=lib/
RUN docker-php-ext-install \
    bcmath \
    ldap \
    pdo_mysql \
    zip
RUN curl --silent --show-error https://getcomposer.org/installer | php && \
    mv composer.phar /usr/local/bin/composer

หลักการทำงานที่ผมจะทำก็คือ

  • Service php_index ผมจะให้รัน WordPress ครับ และผมจะเขียน Dockerfile เพื่อให้ติดตั้ง PHP Extension ที่จำเป็นครับ
  • Service php_dbadmin ผมจะให้รัน phpMyAdmin และผมจะเขียน Dockerfile เพื่อให้ติดตั้ง PHP Extension ที่จำเป็นเช่นกันครับ
  • Service php_laravel ผมจะให้รันโปรเจกต์ Laravel และผมจะเขียน Dockerfile เพื่อให้ติดตั้ง PHP Extension ที่จำเป็น รวมถึง Node.js และ Yarn ด้วย เพื่อใช้ในการ Complie CSS และ JavaScript ครับ

จากนั้นผมจะเขียน php.ini ของแต่ละ PHP Service เพื่อ กำหนดค่าที่จำเป็นต่าง ๆ เช่นอายุ Session ขนาดไฟล์ที่อัปโหลดได้ รวมถึงการเก็บ Log ครับ

; web_index/php/php.ini
error_log = /var/log/php/error.log
post_max_size = 128M
session.gc_maxlifetime=86400
upload_max_filesize = 128M
; web_dbadmin/php/php.ini
post_max_size = 16M
upload_max_filesize = 16M
session.gc_maxlifetime=86400
; web_laravel/php/php.ini
session.gc_maxlifetime=86400

และผมจะเขียน nginx.conf ของแต่ละ Web Service เพื่อให้เรียกใช้ PHP-FPM ครับ

# web_index/conf.d/nginx.conf
map $http_x_forwarded_proto $fast_cgi_https_var {
    default '';
    https 'on';
}
server {
    listen 80;
    index index.php index.html;
    server_name example.com;
    error_log  /var/log/nginx/error.log;
    access_log /var/log/nginx/access.log;
    root /usr/share/nginx/html;
    client_max_body_size 128M;
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }
    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass php_index:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
        fastcgi_param HTTPS $fast_cgi_https_var;
    }
}
# web_dbadmin/conf.d/nginx.conf
map $http_x_forwarded_proto $fast_cgi_https_var {
    default '';
    https 'on';
}
server {
    listen 80;
    index index.php index.html;
    server_name dbadmin.example.com;
    error_log  /var/log/nginx/error.log;
    access_log /var/log/nginx/access.log;
    root /usr/share/nginx/html;
    client_max_body_size 16M;
    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass php_dbadmin:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
        fastcgi_param HTTPS $fast_cgi_https_var;
    }
}
# web_laravel/conf.d/nginx.conf
map $http_x_forwarded_proto $fast_cgi_https_var {
    default '';
    https 'on';
}
server {
    index index.php index.html;
    server_name laravel.example.com;
    error_log  /var/log/nginx/error.log;
    access_log /var/log/nginx/access.log;
    root /usr/share/nginx/html/public;
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }
    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass php_laravel:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
        fastcgi_param HTTPS $fast_cgi_https_var;
    }
}

และผมจะเขียน nginx.conf ของ Reverse Proxy เพื่อให้ Serve เว็บจาก 3 Web Service รวมถึงทำ Origin SSL ซึ่งใช้ Cert จาก Cloudflare ครับ

# nginx/conf.d/nginx.conf
ssl_certificate /etc/nginx/certs/example.com.crt;
ssl_certificate_key /etc/nginx/certs/example.com.key;
server {
    listen 443 ssl;
    server_name example.com;
    client_max_body_size 128M;
    location / {
        proxy_pass http://web_index:80;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_cache_bypass $http_upgrade;
    }
}
server {
    listen 443 ssl;
    server_name dbadmin.example.com;
    client_max_body_size 16M;
    location / {
        proxy_pass http://web_dbadmin:80;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_cache_bypass $http_upgrade;
    }
}
server {
    listen 443 ssl;
    server_name laravel.example.com;
    location / {
        proxy_pass http://web_laravel:80;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_cache_bypass $http_upgrade;
    }
}

จากนั้นผมก็จะยัดไฟล์เว็บเข้าในโฟลเดอร์ */www ของแต่ละ Web Service ครับ

เสร็จแล้วรันเว็บได้หลาย ๆ อันพร้อมกันได้แล้วด้วยคำสั่ง

docker-compose up

หรือถ้าต้องการรันเว็บแบบพื้นหลังไม่ให้พ่น Log ก็สามารถทำได้ด้วย

docker-compose up -d

แต่เอาจริง ๆ ตั้งแรกผมว่า docker-compose up ก่อนเผื่อมี Error ครับ

หรือถ้าต้องการหยุด Service ชั่วคราว ทำได้โดย

docker-compose stop

Docker มันง่ายอย่างงี้แหละ ถามว่ามันง่ายตรงไหน มันจะง่ายตรงที่เราจะนำเว็บไปย้ายที่ Server ตัวอื่น เราเขียน Script ทั้งหมดแล้ว คราวนี้ก็ง่ายแล้ว เพราะสุดท้าย Docker เปรียบเสมือน Virtual Machine ถ้าเครื่องไหนรัน Docker ได้ ก็ง่ายแค่รัน Script ตามที่เขียนไว้ แล้วใช้คำสั่งตามที่ผมบอก ก็ได้ Server ที่พร้อมใช้งาน

ความยากอยู่ตรงไหนบ้าง ?

ส่วนใหญ่เกิดจากความไม่เข้าใจในการทำงานของ Reverse Proxy และพื้นฐานของ Linux ที่ไม่แน่นพอ ทำให้อาจจะมีปัญหาบ้าง แต่พอได้คำปรึกษาดี ๆ จากเพื่อน ทำให้เราก้าวข้าวปัญหาต่าง ๆ มาได้ ต้องขอบคุณเพื่อนจริง ๆ ครับ

Author

พงษ์พันธุ์เป็นคนธรรมดาที่รักการพัฒนาเว็บ ตลอดเวลาที่ว่างเขาฝึกฝนตนเองในการพัฒนาเว็บเป็นประจำ เช่น เรียนรู้สิ่งใหม่ ๆ เกี่ยวกับเทคโนโลยีเว็บ และค้นหาวิธีที่ดีที่สุดในการทำงานร่วมกันเพื่อให้ผู้อื่นเข้าใจตรงกัน และทำงานร่วมกันให้สำเร็จ