Đây là một bài viết rất dài và chi tiết cách tôi viết Dockerfile build thành Docker Image chỉ 143KB, tôi sẽ giải thích cặn kẽ nên bác nào chịu khó đọc thì có thể vỡ ra nhiều điều nhé. Mong vậy :))
Dockerfile contest 2025 dậy sóng thật sự, hôm ấy thấy hàng ngàn người xem webinar trực tiếp rồi thấy các sếp quản trị viên đăng thông tin hàng triệu lượt xem, chục ngàn lượt tương tác các nội dung sự kiện. Phần tôi nghĩ là đặc biệt cũng vì có bác chuyên gia thắng giải với Dockerfile build thành docker image chỉ vỏn vẹn 218KB mà vẫn chạy được lên website.
Tôi thì chắc chắn không phải chọc gậy bánh xe rồi. Và cũng nhờ bác chuyên gia này tôi mới biết có những phương pháp không tưởng để tối ưu giảm size tối đa cho các static website. Nên bác chuyên gia đoạt giải là hoàn toàn xứng đáng, vì nếu bác nào không phục thì tại sao không tham gia. Còn tôi thì tham gia nhưng mà tạch nhé :)))
Nên khi các Dockerfile TOP được công bố tại Dockerfile Contest 2025 là tôi ngâm cứu ngay. Và kết quả đây, một Docker Image với kích thước cuối cùng 143KB và vẫn đáp ứng yêu cầu là không sửa source code.

Và đương nhiên vẫn hoàn toàn chạy được lên website thì mới dám viết bài.

Làm xong thấy cũng muốn chia sẻ vì biết đâu các hoạt động sắp tới hay năm sau mạnh mẽ hơn nữa anh em cộng đồng lại càng được nhờ, quả sự kiện đúng quá ngon, ông thì được học hỏi free toàn kinh nghiệm thật, ông thì được giải, ông thì được tôn vinh, ông thì được thỏa mãn chia sẻ, ví dụ như tôi chẳng được giải gì cũng thích chia sẻ :)))
Chém thế thôi tôi vào luôn các bác nhé, vì theo các sếp quản trị thì vì muốn cộng đồng dễ dàng tiếp cận và cả các bạn ít kinh nghiệm cũng hứng thú thi nên là dự án nó cũng rất template thôi nên tại vì sao có thể tối ưu nhỏ như vậy các bác có thể download source code để về thử nhé vite-react-template.zip
Phân tích Dockerfile đoạt giải
Trước tiên, như tôi đã nói vẫn phải phân tích Dockerfile đoạt giải để biết sâu hơn và nghiên cứu ra các cái liên quan nữa.
Khi nhìn vào Dockerfile đoạt giải, điều đầu tiên tôi thấy là sự hiểu biết sâu sắc vấn đề của tác giả. Bác ấy dùng node:alpine để build, dùng lipanski/docker-static-website để serve static site, có pre-compress, có health-check tối giản, và đặc biệt là từng bước trong builder stage đều được đặt đúng chỗ để tận dụng cache.
Nói thật là nếu không xem Dockerfile đoạt giải này, tôi cũng không nghĩ đến chuyện chạm vào mức tối ưu như vậy. Kiểu như đây là mẫu mà nhìn vào là biết người viết rất hiểu build static site theo chuẩn Vite.
Tôi học được gì từ Dockerfile đoạt giải?
Có ba điểm tôi thấy đáng giá:
- Phân tách build stage và runtime stage hợp lý: Build bằng Node Alpine (đủ nhẹ nhưng còn support đầy đủ toolchain). Runtime thì chuyển sang static server siêu nhỏ.
- Pre-compress toàn bộ output bằng
gzip -9: Đây thực sự là một điểm cực kỳ giá trị. Số lượng file.js/.csscủa Vite build ra rất lớn, vàgzip -9giúp giảm size cực mạnh. - Static website base image hợp lý:
lipanski/docker-static-websitelà base image được tinh giảm đến mức tối đa. Một runtime environment chưa đến 100KB mà vẫn chạy ổn định.
Ba thứ này làm cho Dockerfile đoạt giải trở thành một cấu hình chuẩn chỉnh và khó để chê.
Bắt tay vào viết Docker Image chỉ 143KB
Nhưng tôi vẫn còn một câu hỏi: “Nếu bỏ luôn base image, tự build server, và chạy từ scratch hoàn toàn, thì nhỏ được đến đâu?”
Base image lipanski/docker-static-website là cực kỳ nhẹ, nhưng bản chất vẫn là một image có sẵn. Tôi thử xem nếu tôi tự build một HTTP server từ C, compile static hoàn toàn, strip và UPX nén cực mạnh, rồi chạy trên FROM scratch, liệu cấu trúc này có rơi xuống dưới mức đó hay không.
Đó là lý do tôi chọn con đường:
- Không base image.
- Runtime hoàn toàn từ scratch.
- Một binary duy nhất để serve file.
- Tất cả static assets của Vite được nén lại và giữ nguyên tên file (server gửi header Content-Encoding: gzip).
Tại sao viết HTTP server bằng C?
Đây là chính là lý do làm cho image nhỏ đến mức mà tôi cũng rất wow.
BusyBox httpd vốn đã rất nhỏ, nhưng nếu tôi build một HTTP server:
- chỉ hỗ trợ GET
- không hỗ trợ directory listing
- không support range request
- không handle multithread
- không support MIME đầy đủ
- không có runtime flags
- chỉ đọc file gzip và bơm thẳng ra socket
…thì binary cuối cùng chỉ còn khoảng vài chục KB sau khi:
gcc -Osđể tối ưu size- strip –strip-all để bỏ symbol
- upx –lzma để nén binary tối đa.
Đây là một cách tiếp cận kiểu “zero dependency”. Và đúng là nó nhỏ hơn thật.
Tinh chỉnh dist
Phần xử lý dist trong Dockerfile tôi viết hoàn toàn học từ tác giả gốc:
- Xóa sourcemap
- Xóa image/font/media (không gây lỗi cho app React vì code import sẽ không đụng tới những file đó nữa nếu chúng không cần runtime)
- Xóa manifest.json và favicon
- Giữ đúng index.html và bundle .js
- gzip toàn bộ file ngay tại chỗ (gzip -c để tránh rename .gz)
Tôi chỉ thay đổi một việc: server mặc định luôn gửi Content-Encoding: gzip nên chỉ cần giữ file đã gzip, không cần bản thô, cách này giảm được kha khá dung lượng.
Sự khác biệt lớn nhất: runtime từ scratch
Ở Dockerfile của tôi, runtime chỉ là:
- Một binary C khoảng vài chục KB
- Một thư mục dist đã được gzip
- Không shell
- Không OS
- Không libc động (static hoàn toàn)
- Không TLS
- Không bất cứ dependency nào ngoài kernel
- Hệ quả là image dựng lên mức thấp nhất có thể.
Docekerfile build thành Docker Image chỉ 143KB
Dưới đây là toàn bộ Dockerfile mà tôi viết:
# syntax=docker/dockerfile:1.7
############################
# Stage 1: build tiny static HTTP server
############################
FROM alpine:3.22.2 AS http-server-builder
WORKDIR /src
RUN apk add --no-cache build-base upx
# Viết và build HTTP server C, serve file từ ./ trên port 3000
RUN <<EOF
set -e
cat > tiny_httpd.c <<'SRC'
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
static void die(const char *msg) {
perror(msg);
exit(1);
}
static const char *guess_type(const char *path) {
size_t len = strlen(path);
if (len >= 5 && strcmp(path + len - 5, ".html") == 0) return "text/html; charset=utf-8";
if (len >= 4 && strcmp(path + len - 4, ".css") == 0) return "text/css; charset=utf-8";
if (len >= 3 && strcmp(path + len - 3, ".js") == 0) return "application/javascript; charset=utf-8";
if (len >= 5 && strcmp(path + len - 5, ".json") == 0) return "application/json; charset=utf-8";
if (len >= 4 && strcmp(path + len - 4, ".svg") == 0) return "image/svg+xml";
if (len >= 4 && strcmp(path + len - 4, ".txt") == 0) return "text/plain; charset=utf-8";
return "application/octet-stream";
}
static void send_simple(int client, int code, const char *reason, const char *body) {
char buf[512];
size_t body_len = strlen(body);
int n = snprintf(buf, sizeof(buf),
"HTTP/1.1 %d %s\r\n"
"Content-Type: text/plain; charset=utf-8\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n"
"%s",
code, reason, body_len, body);
send(client, buf, n, 0);
}
static void handle_client(int client) {
char req[2048];
ssize_t r = read(client, req, sizeof(req) - 1);
if (r <= 0) {
close(client);
return;
}
req[r] = '\0';
if (strncmp(req, "GET ", 4) != 0) {
send_simple(client, 405, "Method Not Allowed", "Only GET supported\n");
close(client);
return;
}
char *path = req + 4;
char *space = strchr(path, ' ');
if (!space) {
send_simple(client, 400, "Bad Request", "Malformed request line\n");
close(client);
return;
}
*space = '\0';
if (strstr(path, "..")) {
send_simple(client, 400, "Bad Request", "Invalid path\n");
close(client);
return;
}
char full[1024];
if (strcmp(path, "/") == 0) {
snprintf(full, sizeof(full), "./index.html");
} else {
snprintf(full, sizeof(full), ".%s", path);
}
int fd = open(full, O_RDONLY);
if (fd < 0) {
send_simple(client, 404, "Not Found", "Not found\n");
close(client);
return;
}
struct stat st;
if (fstat(fd, &st) < 0 || !S_ISREG(st.st_mode)) {
close(fd);
send_simple(client, 404, "Not Found", "Not found\n");
close(client);
return;
}
const char *ctype = guess_type(full);
char header[512];
int n = snprintf(header, sizeof(header),
"HTTP/1.1 200 OK\r\n"
"Content-Type: %s\r\n"
"Content-Length: %ld\r\n"
"Content-Encoding: gzip\r\n"
"Connection: close\r\n"
"\r\n",
ctype, (long)st.st_size);
send(client, header, n, 0);
char buf[4096];
ssize_t nr;
while ((nr = read(fd, buf, sizeof(buf))) > 0) {
ssize_t off = 0;
while (off < nr) {
ssize_t nw = send(client, buf + off, nr - off, 0);
if (nw <= 0) break;
off += nw;
}
}
close(fd);
close(client);
}
int main(void) {
signal(SIGPIPE, SIG_IGN);
int s = socket(AF_INET, SOCK_STREAM, 0);
if (s < 0) die("socket");
int opt = 1;
setsockopt(s, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(3000);
if (bind(s, (struct sockaddr *)&addr, sizeof(addr)) < 0) die("bind");
if (listen(s, 16) < 0) die("listen");
for (;;) {
int client = accept(s, NULL, NULL);
if (client < 0) continue;
handle_client(client);
}
}
SRC
gcc -Os -static -fdata-sections -ffunction-sections -Wl,--gc-sections tiny_httpd.c -o tiny-httpd
strip --strip-all tiny-httpd
upx --best --lzma tiny-httpd || true
EOF
############################
# Stage 2: build Vite React, bóc dist, gzip inplace
############################
FROM node:22.21.1-alpine3.21 AS vite-react-template-builder
WORKDIR /src
RUN apk add --no-cache gzip \
&& corepack enable \
&& corepack prepare pnpm@latest --activate
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm install --frozen-lockfile
COPY . .
RUN CI=true pnpm build \
&& cd dist \
&& find . -name "*.map" -delete \
&& find . -type f \( \
-name "*.png" -o \
-name "*.jpg" -o \
-name "*.jpeg" -o \
-name "*.gif" -o \
-name "*.webp" -o \
-name "*.ico" -o \
-name "*.avif" -o \
-name "*.bmp" -o \
-name "*.mp4" -o \
-name "*.webm" -o \
-name "*.mp3" -o \
-name "*.ogg" -o \
-name "*.wav" -o \
-name "*.woff" -o \
-name "*.woff2" -o \
-name "*.ttf" -o \
-name "*.eot" -o \
-name "*.otf" \
\) -delete \
&& find . -type f -name "LICENSE*" -delete \
&& rm -f manifest*.json robots.txt favicon.* || true \
&& for f in $(find . -type f); do \
gzip -9c "$f" > "$f.gz" && mv "$f.gz" "$f"; \
done
############################
# Stage 3: final image từ scratch
############################
FROM scratch
WORKDIR /app/dist
COPY --from=http-server-builder /src/tiny-httpd /tiny-httpd
COPY --from=vite-react-template-builder /src/dist /app/dist
ENTRYPOINT ["/tiny-httpd"]
Giải thích chi tiết
Dưới đây là chi tiết từng stage, từng bước cụ thể cách tôi xây dựng lên Dockerfile này các bác có thể xem kỹ nhé. Cũng xin được cảm ơn anh bạn trong box chat đã thị phạm chia sẻ thẳng thắn cho những gợi ý giá trị.
Stage 1: Tự build một HTTP server siêu nhỏ từ C
FROM alpine:3.22.2 AS http-server-builder
Chọn Alpine vì:
- Có đủ toolchain (gcc, musl-dev)
- Thư viện nhẹ
- Rất phù hợp để build binary tĩnh (static binary)
Sau đó tôi cài compiler + upx:
RUN apk add --no-cache build-base upx
Vì sao phải tự build HTTP server?
Vì tất cả server có sẵn (BusyBox httpd, nginx, caddy, lighttpd, thậm chí tiny-http-server có sẵn từ internet):
- Đều có tính năng thừa so với static web minimal
- Đều kéo theo dependency
- Đều có kích thước lớn hơn mức tối thiểu tuyệt đối
Tự build server cho phép tôi:
- Giảm mọi thứ xuống mức tối giản (chỉ GET, không HEAD, không range request)
- Serve file gzip mà không cần file gốc
- Không thư viện runtime
- Không shell
- Không glibc (static compile dùng musl)
Chèn trực tiếp mã C vào Dockerfile
Đây là đoạn quan trọng:
RUN <<EOF
set -e
cat > tiny_httpd.c <<'SRC'
... toàn bộ mã C ...
SRC
gcc -Os -static -fdata-sections -ffunction-sections -Wl,--gc-sections tiny_httpd.c -o tiny-httpd
strip --strip-all tiny-httpd
upx --best --lzma tiny-httpd || true
EOF
Tại sao viết mã C ngay trong Dockerfile?
- Không tạo file mới trong repo.
- Dễ inline, dễ build, dễ xoá.
- Dockerfile có thể tự sinh binary mà không cần file bên ngoài.
Tối ưu compiler
-Os: tối ưu size-static: compile tĩnh 100%, không cần runtime libc ở layer cuối--gc-sections: loại bỏ hàm không dùngstrip: bỏ symbolupx --lzma: nén binary tới mức tối đa có thể
Binary cuối thường chỉ vài chục KB.
Stage 2: Build Vite, bóc dist, nén cực mạnh
FROM node:22.21.1-alpine3.21 AS vite-react-template-builder
Giống Dockerfile đoạt giải: build bằng Node Alpine.
Cài gzip, pnpm
RUN apk add --no-cache gzip \
&& corepack enable \
&& corepack prepare pnpm@latest --activate
Giữ nguyên toàn bộ source
COPY . .
Không xóa gì trong src đúng yêu cầu.
Sau khi build xong
CI=true pnpm build
Ra thư mục dist.
Và tôi bắt đầu “bóc”:
Xóa sourcemap, hình ảnh, font, media
find . -name "*.map" -delete
find . -type f (...) -delete
Mục đích:
- file map không cần cho runtime -> xóa
- ảnh/video/audio/font nếu app không cần render -> xóa
- nếu không xóa thì size dist tăng hàng chục KB đến vài trăm KB
Xóa LICENSE, manifest, robots, favicon
find . -type f -name "LICENSE*" -delete
rm -f manifest*.json robots.txt favicon.*
Không ảnh hưởng Vite runtime.
Gzip toàn bộ file còn lại
gzip -9c "$f" > "$f.gz" && mv "$f.gz" "$f"
Điểm quan trọng:
- Tôi không để file .gz song song với file thô
- Tôi nén in-place => file gốc bị thay thế bởi file gzip
- HTTP server tự thêm header
Content-Encoding: gzipkhi gửi trả file
Kết quả:
- dist nhỏ hơn đáng kể
- runtime không cần logic phức tạp để tìm file .gz
Stage 3: Final image từ scratch
FROM scratch
Đây là lý do image nhỏ đến mức không tưởng.
Scratch:
- Không shell
- Không libc
- Không thư viện
- Không file system ngoài dist
- Không metadata thừa
Chỉ có:
COPY tiny-httpd
COPY dist
ENTRYPOINT ["/tiny-httpd"]
Toàn bộ tiến trình Docker build
root@dockerfile-contest-2025:~/vite-react-template# DOCKER_BUILDKIT=1 docker build -t vite-dockerfile-contest-2025:v1 .
[+] Building 56.1s (22/22) FINISHED docker:default
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 6.28kB 0.0s
=> resolve image config for docker-image://docker.io/docker/dockerfile 4.3s
=> docker-image://docker.io/docker/dockerfile:1.7@sha256:a57df69d0ea82 2.0s
=> => resolve docker.io/docker/dockerfile:1.7@sha256:a57df69d0ea827fb7 0.0s
=> => sha256:a57df69d0ea827fb7266491f2813635de6f17269b 8.40kB / 8.40kB 0.0s
=> => sha256:b5f3b260a9678e1d83d2fce86eeddf79420b79147eaba 482B / 482B 0.0s
=> => sha256:68ebc061390d9a7d6e194f9d58309c754a53cb8b4 1.26kB / 1.26kB 0.0s
=> => sha256:96918c57e42509b97f10c074d80672ecdbd3bb7 11.98MB / 11.98MB 1.8s
=> => extracting sha256:96918c57e42509b97f10c074d80672ecdbd3bb7dcd38c1 0.1s
=> [internal] load metadata for docker.io/library/node:22.21.1-alpine3 4.0s
=> [internal] load metadata for docker.io/library/alpine:3.22.2 3.4s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [vite-react-template-builder 1/7] FROM docker.io/library/node:22.21 9.1s
=> => resolve docker.io/library/node:22.21.1-alpine3.21@sha256:af8023e 0.0s
=> => sha256:f637881d1138581d892d9eb942c56e0ccc7758fe3 3.64MB / 3.64MB 1.2s
=> => sha256:de18c5931176dd4dab1a9d4abf6d452de279db3 51.57MB / 51.57MB 7.4s
=> => sha256:af8023ec879993821f6d5b21382ed915622a1b0f1 6.43kB / 6.43kB 0.0s
=> => sha256:9bd3e11261b7cc25990e76327cf785e3316523270 1.72kB / 1.72kB 0.0s
=> => sha256:7a0727174f9047ac1e26612e0bf6f5fb8e08195b7 6.50kB / 6.50kB 0.0s
=> => extracting sha256:f637881d1138581d892d9eb942c56e0ccc7758fe3bdc0f 0.1s
=> => sha256:0c8fa532fea181cae7b7c21c29bff9f7e0c627031 1.26MB / 1.26MB 2.6s
=> => sha256:fb003c71d3fb14a51108d918f97134fb5dd531f132f95 444B / 444B 2.8s
=> => extracting sha256:de18c5931176dd4dab1a9d4abf6d452de279db356a1f71 1.5s
=> => extracting sha256:0c8fa532fea181cae7b7c21c29bff9f7e0c627031e27a4 0.0s
=> => extracting sha256:fb003c71d3fb14a51108d918f97134fb5dd531f132f950 0.0s
=> [stage-2 1/3] WORKDIR /app/dist 0.0s
=> [internal] load build context 0.0s
=> => transferring context: 1.13MB 0.0s
=> [http-server-builder 1/4] FROM docker.io/library/alpine:3.22.2@sha2 2.3s
=> => resolve docker.io/library/alpine:3.22.2@sha256:4b7ce07002c69e8f3 0.0s
=> => sha256:4b7ce07002c69e8f3d704a9c5d6fd3053be500b7f 9.22kB / 9.22kB 0.0s
=> => sha256:85f2b723e106c34644cd5851d7e81ee87da98ac54 1.02kB / 1.02kB 0.0s
=> => sha256:706db57fb2063f39f69632c5b5c9c439633fda35110e6 581B / 581B 0.0s
=> => sha256:2d35ebdb57d9971fea0cac1582aa78935adf8058b 3.80MB / 3.80MB 2.2s
=> => extracting sha256:2d35ebdb57d9971fea0cac1582aa78935adf8058b2cc32 0.1s
=> [http-server-builder 2/4] WORKDIR /src 0.0s
=> [http-server-builder 3/4] RUN apk add --no-cache build-base upx 15.3s
=> [vite-react-template-builder 2/7] WORKDIR /src 0.2s
=> [vite-react-template-builder 3/7] RUN apk add --no-cache gzip && c 8.8s
=> [http-server-builder 4/4] RUN <<EOF (set -e...) 0.5s
=> [vite-react-template-builder 4/7] COPY package.json pnpm-lock.yaml 0.0s
=> [vite-react-template-builder 5/7] RUN --mount=type=cache,id=pnpm-s 13.3s
=> [stage-2 2/3] COPY --from=http-server-builder /src/tiny-httpd /tiny 0.0s
=> [vite-react-template-builder 6/7] COPY . . 0.0s
=> [vite-react-template-builder 7/7] RUN CI=true pnpm build && cd di 14.0s
=> [stage-2 3/3] COPY --from=vite-react-template-builder /src/dist /ap 0.0s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:f3fd0747b3f4d20b1fadd9abbae6d187b7bbabf204e 0.0s
=> => naming to docker.io/library/vite-dockerfile-contest-2025:v1 0.0s
Kích thước Dockerfile cuối cùng của dự án vite-react-template trong Dockerfile Contest 2025 mà tôi viết lại là 143KB. Hy vọng bài viết chi tiết này giúp các bác thêm được phần nào kinh nghiệm. Nếu bác nào có thể tối ưu size còn nhỏ hơn được nữa thì tôi xin được học hỏi nhé.







