Giới thiệu
Chào các bạn, tiếp tục với chuỗi series tìm hiểu sâu hơn về Container. Chúng ta đã biết Container được xây dựng từ Linux Namespaces và Cgroups, ở bài này chúng ta sẽ tìm hiểu kĩ hơn về Container nữa bằng cách tự xây dựng một Container sử dụng ngôn ngữ Golang.
Đây là bài cuối trong chuỗi bài về Container:
- Linux Namespaces và Cgroups: Container được xây dựng từ gì?
- Tìm hiểu sâu hơn về Container - Container Runtime là gì?
- Kubernetes làm việc với Container như thế nào?
- Tự xây dựng Container với Go
Building a Container
Ta tạo một file tên là container.go
và viết cho nó một số đoạn code đơn giản như sau.
package main
import (
"os"
)
func main() {
}
func must(err error) {
if err != nil {
panic(err)
}
}
Nếu bạn có xài Docker thì ta sẽ biết câu lệnh để chạy container là docker run <container> <command>
, ví dụ ta chạy câu lệnh sau:
docker run busybox echo "A"
Bạn sẽ thấy container chạy và in ra chữ “A”, còn ví dụ bạn chạy câu lệnh sau:
docker run -it busybox sh
/ #
Bạn sẽ thấy nó chạy container và gán sh vào container đó, nếu lúc này ta gõ command thì command đó đang chạy trong container.
/ # hostname
d12ccc0e00a0
/ # ps
PID USER TIME COMMAND
1 root 0:00 sh
9 root 0:00 ps
Khi bạn chạy câu lệnh hostname
thì sẽ thấy nó in ra hostname của container chứ không phải của server. Và khi ta chạy câu lệnh ps
thì ta sẽ thấy trong container nó chỉ có hai process là sh
lúc ta chạy container busybox
với command là sh
và ps
mà ta vữa gõ.
Giờ ta sẽ xây dựng một container tương tự như trên bằng Go, cập nhật lại file container.go
như sau.
package main
import (
"os"
)
// docker run <image> <command>
// go run container.go run <command>
func main() {
switch os.Args[1] {
case "run":
run()
default:
panic("Error")
}
}
func run() {
}
func must(err error) {
if err != nil {
panic(err)
}
}
Ta thêm vào một hàm tên là run()
và ở trong hàm main, ta dùng switch case để kiểm tra khi ta chạy chương trình với flag là run thì nó sẽ chạy hàm run()
. Lúc này khi ta chạy câu lệnh go run container.go run
thì nó sẽ tương tự như khi ta chạy docker run
.
Tiếp theo ta cập nhật hàm run()
như sau.
package main
import (
"os"
"os/exec"
)
...
func run() {
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
must(cmd.Run())
}
...
Ta sẽ dùng package os/exec
để thực thi câu lệnh của người dùng nhập vào mà được lưu trong mảng os.Args
, ví dụ khi ta gõ go run container.go run echo "A"
, thì mảng Args sẽ có giá trị là:
Args[0] = "container.go"
Args[1] = "run"
Args[2] = "echo"
Args[3] = "A"
Với giá trị ta cần truyền vào hàm exec.Command()
thì ta sẽ lấy từ index thứ 2 trở đi, hàm exec.Command()
sẽ nhận tham số thứ nhất là câu lệnh mà nó sẽ thực thi, và các giá trị còn lại là args của câu lệnh đó.
exec.Command(name string, arg ...string)
Ở cuối hàm ta dùng cmd.Run()
để thực thi câu lệnh mà ta truyền vào go run container.go run
. Ok, giờ bạn thử chạy câu lệnh giống với docker run -it busybox sh
bằng chương trình của ta, nhớ chuyển sang root user để chạy nếu bạn chạy bằng linux.
go run container.go run sh
#
Ta sẽ thấy là nó đã chạy được y change khi ta chạy câu lệnh docker, ta đã thành công bước đầu tiên 😁, nhưng khi bạn gõ hostname thì nó sẽ lấy hostname của server của ta chứ không phải của container ta vừa tạo bằng file container.go
.
# hostname
LAPTOP-2COB82RG
Khi bạn gõ câu lệnh để thay đổi hostname trong chương trình của ta thì nó cũng sẽ ảnh hưởng tới bên ngoài server luôn.
# hostnamectl set-hostname container
Gõ exit để thoát, và giờ ở ngoài server ta gõ lại hostname
ta sẽ thấy nó đã bị thay đổi. Chương trình của ta hiện tại chỉ là chạy câu lệnh sh
thôi, chứ không phải container gì cả, tiếp theo ta sẽ đi qua từng bước để xây container nào.
Như ta đã biết container được xây dựng từ Linux Namespaces và Cgroups, đầu tiên ta sẽ sử dụng tính năng Namespaces của Linux để xây container.
Namespaces
Namespaces sẽ giúp ta chạy một process độc lập hoàn toàn với các process khác trên cùng một server, tại thời điểm mình viết có 6 namespaces như sau:
- PID: giúp ta tạo process với PID tách biệt với các process khác trên server.
- MNT: giúp ta có thể mount và unmount file mà không ảnh hưởng gì tới file trên server.
- NET: giúp ta tạo một network namepsace độc lập.
- UTS: giúp process có hostname và domain name riêng biệt.
- USER: giúp ta tạo user namespace tách biệt với server.
Ta sẽ dùng các namespaces ở trên để chương trình chạy bằng Go của ta có process độc lập giống như container vậy.
UTS namespace
Thứ đầu tiên ta cần tách biệt là hostname, để chương trình của ta có hostname riêng. Ta sẽ dùng UTS namespace, cập nhật file container.go
như sau:
package main
import (
"os"
"os/exec"
"syscall"
)
...
func run() {
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
}
must(cmd.Run())
}
...
Để sử dụng Linux namespaces ở trong Go, ta chỉ đơn giản truyền tên namespace mà ta muốn xài vào cmd.SysProcAttr
.
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
}
ở đây tên namespace của UTS là syscall.CLONE_NEWUTS
. Giờ ta chạy lại thử.
go run container.go run sh
Giờ bạn chạy câu lệnh thay đổi hostname.
# hostnamectl set-hostname wsl
# hostname
wsl
Sau khi thay đổi hostname xong bạn chạy lại hostname
ta sẽ thấy nó đã đổi, tuy nhiên nếu ta gõ exit
và thoát ra khỏi chương trình, gõ lại hostname
ở server ta sẽ thấy nó vẫn như cũ chứ không hề bị thay đổi.
Vậy là ta đã thành công bước tiếp theo trong việc xây dựng container 😁. Tuy nhiên để chương trình của ta giống với container hơn tương tự như ta chạy docker run
, ta cần làm thêm một số thứ nho nhỏ.
Như bạn thấy khi ta chạy docker run -it busybox sh
rồi gõ hostname
nó sẽ tự có hostname riêng, chứ không phải giống ta chạy chương trình xong, ta phải tự gõ câu lệnh để thay đổi hostname. Cập nhật lại file container.go
.
package main
import (
"os"
"os/exec"
"syscall"
)
// docker run <image> <command>
// ./container run <command>
func main() {
switch os.Args[1] {
case "run":
run()
case "child":
child()
default:
panic("Error")
}
}
func run() {
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS,
}
must(cmd.Run())
}
func child() {
syscall.Sethostname([]byte("container"))
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
must(cmd.Run())
}
func must(err error) {
if err != nil {
panic(err)
}
}
Thay đổi khá nhiều phải không :)))
, mình sẽ giải thích từ từ. Điểm đầu tiên ta thay đổi là ta thêm vào một hàm nữa là child()
, và ở trong hàm run ta sẽ thực thi hàm child này bằng cách update lại exec.Command
exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
Ta thay parameter đầu tiên thành /proc/self/exe
, có nghĩa là exec
sẽ thực thi lệnh /proc/self/exe
. Lệnh này có chức năng là tự thực thi lại chương trình, có nghĩa là chương trình container.go
của ta sẽ tự thực thi lại và truyền vào args là child.
Ở trong hàm child, lúc này nó đã chạy ở một process mà có UTS namespace độc lập, ta set lại hostname cho nó bằng hàm syscall.Sethostname([]byte("container"))
, lúc này chương trình của ta sẽ có hostname riêng mà không ảnh hưởng gì tới server.
Sau đó, ở trong chương trình child này ta thực thi args mà ban đầu ta đã truyền vào. Tiến trình như sau.
go run container.go run sh
-> /proc/self/exe child sh
-> syscall.Sethostname([]byte("container"))
-> exec.Command("sh")
.
Giờ chạy thử nào.
go run container.go run sh
# hostname
container
Ngon lành, vậy là ta đã thành công bước tiếp theo 😁. Bạn gõ thử ps
để liệt kê process ra nào, xem nó có giống với lúc ta chạy docker run
không.
# ps
PID TTY TIME CMD
11254 pts/3 00:00:00 sudo
11255 pts/3 00:00:00 bash
17530 pts/3 00:00:00 go
17626 pts/3 00:00:00 container
17631 pts/3 00:00:00 exe
17636 pts/3 00:00:00 sh
17637 pts/3 00:00:00 ps
Bạn sẽ thấy nó có rất nhiều process, và đây là những process ở bên ngoài server của ta luôn, bạn gõ exit
để thoát và gõ lại ps
bên ngoài server, bạn sẽ thấy nó liệt kê ra những process giống lúc ta gõ ps
trong chương trình.
PID namespace
Như ta đã nói ở trên, PID namespace sẽ giúp ta tạo một process có PID hoàn toàn độc lập với server bên ngoài, để sử dụng PID namespace ta cập nhật code như sau.
...
func run() {
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID,
}
must(cmd.Run())
}
...
Ta chỉ việc thêm syscall.CLONE_NEWPID
vào, giờ chạy lại nào.
go run container.go run sh
# ps
PID TTY TIME CMD
11254 pts/3 00:00:00 sudo
11255 pts/3 00:00:00 bash
17530 pts/3 00:00:00 go
17626 pts/3 00:00:00 container
17631 pts/3 00:00:00 exe
17636 pts/3 00:00:00 sh
17637 pts/3 00:00:00 ps
Ta sẽ thấy là nó vẫn giống y chang hồi nãy, PID namespace không chạy hả? Thực chất PID namespace sẽ giúp ta chạy các process trong chương trình ở một namespace tách biệt, tuy nhiên nó vẫn có thể liệt kê các process ở dưới server.
Vì bản chất khi ta chạy câu lệnh ps
thì nó sẽ lấy thông tin process ở folder /proc
trong linux, bạn chạy thử sẽ thấy.
ls /proc
Khi ta tạo một process với namespace, filesystem của nó sẽ được kế thừa từ server hiện tại. Do đó, nếu ta muốn process không truy cập được filesystem của server thì ta phải làm mới filesystem của process. Nhưng chương trình ta đang chạy ở trên server, nếu ta làm gì bậy bạ thì filesystem trên server của ta sẽ bị ảnh hưởng. Do đó, ta cần tạo namespace mà khi ta mount filesystem cho nó sẽ không ảnh hưởng gì tới server hết, ta sẽ dùng MNT namespace.
MNT namespace
Ta cập nhật file container.go
như sau để sử dụng MNT namespace.
...
func run() {
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
}
must(cmd.Run())
}
func child() {
syscall.Sethostname([]byte("container"))
must(syscall.Chdir("/"))
must(syscall.Mount("proc", "proc", "proc", 0, ""))
cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
must(cmd.Run())
}
...
Ta sẽ dùng biến syscall.CLONE_NEWNS
để tạo MNT namespace, sau đó ta sẽ làm mới /proc
folder bằng hai hàm.
syscall.Chdir("/")
syscall.Mount("proc", "proc", "proc", 0, "")
Giờ ta chạy lại nào.
go run container.go run sh
# ps
PID TTY TIME CMD
1 pts/3 00:00:00 exe
7 pts/3 00:00:00 sh
8 pts/3 00:00:00 ps
# ls /proc
1 cgroups devices fs kcore kpageflags mounts schedstat sysvipc vmallocinfo
6 cmdline diskstats interrupts key-users loadavg mtrr self thread-self vmstat
9 config.gz dma iomem keys locks net softirqs timer_list zoneinfo
acpi consoles driver ioports kmsg meminfo pagetypeinfo stat tty
buddyinfo cpuinfo execdomains irq kpagecgroup misc partitions swaps uptime
bus crypto filesystems kallsyms kpagecount modules sched_debug sys version
Ta sẽ thấy process bây giờ chỉ có 1 vài thằng và proc/self/exe
của ta đang chạy với PID là 1. Ngon lành cành đào 😁, ta đã xây dựng container thành công.
Kết luận
Vậy là ta đã biết cách xây dựng một container đơn giản bằng Golang, tuy trong thực tế container sẽ còn nhiều thứ khác nữa, như là Cgroups để limit resources của process, tạo USER namespaces, mount file từ container ra bên ngoài, v … v …
Nhưng cơ bản thì tính năng chính để container có thể tạo được một môi trường độc lập là Linux namespaces. Hiểu rõ về container sẽ giúp ta rất nhiều trong việc thao tác với nó.
Nếu bài viết có gì sai hoặc cần cập nhật thì liên hệ Admin.
Tham gia nhóm chat của DevOps VN tại Telegram.
Kém tiếng Anh và cần nâng cao trình độ giao tiếp: Tại sao bạn học không hiệu quả?