Phân quyền file do AI agent sinh ra: X-Accel-Redirect
Bối cảnh
Bài toán: serve file tĩnh có kiểm soát quyền truy cập — ví dụ artifact HTML do hệ thống AI sinh ra cho từng user, báo cáo riêng tư, file đính kèm nội bộ. File là tĩnh, nhưng không thể đặt vào /static/ công khai vì chỉ chủ sở hữu mới được xem.
Cách làm ngây thơ là để Django đọc file và trả bytes:
Client → Nginx → Django → đọc file → trả bytes → Nginx → Client
Chạy được, nhưng Django worker bị chiếm suốt thời gian truyền file, file lớn thì chiếm luôn RAM, và mỗi download đắt ngang một request động. Điều vô lý ở đây: phần khó (kiểm tra quyền) chỉ mất vài ms, phần nặng (đẩy bytes) lại là việc nginx làm giỏi hơn Django cả trăm lần — mà ta đang bắt Django làm cả hai.
X-Accel-Redirect tách đúng chỗ đó: application server chỉ trả lời câu hỏi "user này có được xem file này không", rồi ủy quyền cho nginx serve file qua một internal redirect. Application server là gì không quan trọng — bài này minh họa bằng Django, nhưng FastAPI, Kotlin/Jakarta hay Node đều chơi được, vì hợp đồng chỉ là một HTTP header.
Client → Nginx → Django (chỉ check auth) → X-Accel-Redirect → Nginx serve file → Client
Cơ chế
Toàn bộ ma thuật nằm ở hai chữ: header X-Accel-Redirect trong response của Django không bao giờ đến client. Nginx bắt lấy nó, hiểu là "hãy xử lý lại request này ở location sau đây", và thực hiện internal redirect ngay trong chính nó.
Cấu hình nginx
# Public endpoint — forward đến Django
location /artifact/ {
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Internal location — chỉ nginx nội bộ mới với tới được
location /internal/artifacts/ {
internal;
alias /var/app/artifacts/;
}
Từ khóa internal là chốt an toàn: client request thẳng /internal/artifacts/... sẽ nhận 404 — location này chỉ tồn tại với các internal redirect. Đường dẫn thật của file trên disk cũng ẩn hoàn toàn khỏi client.
Phía Django
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from .models import Artifact
@login_required
def artifact_view(request, artifact_id):
artifact = get_object_or_404(Artifact, id=artifact_id)
if artifact.user_id != request.user.id:
return HttpResponse(status=403)
response = HttpResponse(content_type="text/html")
response["X-Accel-Redirect"] = f"/internal/artifacts/{artifact.file_path}"
return response
Response của Django rỗng — không một byte file nào đi qua nó. Worker được giải phóng ngay sau khi check quyền xong, còn nginx dùng sendfile stream thẳng từ disk.
Schema tối thiểu đi kèm:
CREATE TABLE artifact (
id UUID PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES auth_user(id),
conversation_id VARCHAR(64),
file_path VARCHAR(512) NOT NULL, -- 2025-04/{user_id}/{artifact_id}.html
chart_type VARCHAR(32),
created_at TIMESTAMP DEFAULT NOW()
);
Dùng UUID cho artifact_id thay vì sequential ID — URL dạng /artifact/42/ mời gọi người ta thử /artifact/43/; UUID chặn enumeration ngay từ không gian định danh, còn ownership check là lớp chặn thật.
Ứng dụng: artifact do LLM sinh ra
Ngữ cảnh khiến tôi dùng pattern này: hệ thống AI nội bộ nơi LLM gọi MCP tool để sinh biểu đồ ECharts dưới dạng file HTML. Điểm đáng chú ý về kiến trúc: MCP server là một process hoàn toàn độc lập (Node.js), không liên quan gì đến Django — nó không gọi Django, Django cũng không gọi nó. Ba bên chỉ chia sẻ hai thứ: filesystem và bảng artifact trong DB.
Ghi (MCP server — độc lập):
User hỏi → LLM gọi MCP tool generate_chart
→ MCP render HTML, ghi file /var/app/artifacts/{YYYY-MM}/{user_id}/{artifact_id}.html
→ MCP ghi record vào bảng artifact, trả artifact_id cho LLM
→ LLM trả lời kèm tag <artifact id="..." type="echart"/>
Đọc (Django + nginx — độc lập):
Client parse tag, render iframe trỏ GET /artifact/{id}
→ Django tra bảng artifact, check quyền → X-Accel-Redirect → nginx serve
Hợp đồng giữa hai phía chỉ là schema bảng artifact và quy ước đường dẫn file_path — MCP server ghi theo format nào, Django tin theo format đó. Không API call chéo, không coupling runtime: MCP server chết không ảnh hưởng việc xem artifact cũ, Django chết không ảnh hưởng việc sinh artifact mới. Việc phân quyền trên filesystem cũng tách bạch theo: process MCP cần quyền ghi vào /var/app/artifacts/, nginx chỉ cần quyền đọc, còn Django không đụng vào file — nó chỉ đọc DB.
Đặc điểm của loại file này khớp hoàn hảo với pattern: sinh một lần, đọc nhiều lần, gắn chặt với một user, và có thể nặng (chart nhiều data point). Django chỉ trả lời đúng một câu hỏi mỗi request; mọi thứ còn lại là static serving thuần túy.
So sánh
| Tiêu chí | Django tự đọc file | X-Accel-Redirect |
|---|---|---|
| Worker | Bị chiếm suốt quá trình truyền | Giải phóng ngay sau auth check |
| Memory | Load file vào RAM (hoặc tự chunk) | Nginx sendfile, zero-copy |
| Throughput | Giới hạn bởi số worker | Giới hạn bởi disk/network |
| Path thật của file | Dễ lộ qua lỗi/log | Ẩn hoàn toàn |
| Quyền truy cập | Tùy implement | Django quyết, internal chặn đường vòng |
Lưu ý triển khai
content_typephải set đúng ở Django — nginx forward header này về client. Serve nhiều loại file thì suy ra từ extension hoặc lưu trong DB.- Đường dẫn trong
X-Accel-Redirectphải khớp prefix vớilocation internal— lệch một dấu/giữaaliasvà header là ra 404 khó hiểu; test bằng một file mẫu trước. - Pattern tương đương tồn tại ở Apache/Caddy dưới tên
X-Sendfile— khác header, cùng ý tưởng. - File sinh mãi không dọn thì disk đầy: cần cleanup policy (cron xóa artifact quá hạn, hoặc TTL theo
created_at), và nhớ xóa cả record DB lẫn file — mồ côi bên nào cũng phiền.
Kết
X-Accel-Redirect là ví dụ đẹp của nguyên tắc "mỗi tầng làm đúng việc của nó": application server quyết định ai được xem gì, reverse proxy lo đẩy bytes. Không thêm hạ tầng mới, không thêm dependency — chỉ một header và một location block, đổi lại throughput serve file tăng cả bậc độ lớn.