Kubernetes Story – Running container without Docker – Build your own container with Go

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ự build một container sử dụng ngôn ngữ Golang. Các bạn

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ự build một container sử dụng ngôn ngữ Golang.

image.png

Các bạn nên đọc 3 bài trước đó để hiểu rõ hơn về container và cách Kubernetes tương tác với nó như thế nào nhé:

  1. Linux namespaces and Cgroups: What are containers made from?
  2. Deep into Container Runtime.
  3. How Kubernetes works with Container Runtime.

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")funcmain(){}funcmust(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à shps 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>funcmain(){switch os.Args[1]{case"run":run()default:panic("Error")}}funcrun(){}funcmust(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")...funcrun(){
	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")...funcrun(){
	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>funcmain(){switch os.Args[1]{case"run":run()case"child":child()default:panic("Error")}}funcrun(){
	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())}funcchild(){
	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())}funcmust(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 sudo11255 pts/3    00:00:00 bash17530 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 sh17637 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.

...funcrun(){
	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 sudo11255 pts/3    00:00:00 bash17530 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 sh17637 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.

...funcrun(){
	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())}funcchild(){
	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 sh8   pts/3    00:00:00 ps# ls1          cgroups    devices      fs          kcore        kpageflags  mounts        schedstat  sysvipc      vmallocinfo
6          cmdline    diskstats    interrupts  key-users    loadavg     mtrr          self       thread-self  vmstat9          config.gz  dma          iomem       keys         locks       net           softirqs   timer_list   zoneinfo
acpi       consoles   driver       ioports     kmsg         meminfo     pagetypeinfo  stattty
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 có thắc mắc hoặc cần giải thích rõ thêm chỗ nào thì các bạn có thể hỏi dưới phần comment.

Mục tìm kiếm đồng đội

Hiện tại thì công ty bên mình, là Hoàng Phúc International, với hơn 30 năm kinh nghiệm trong lĩnh vực thời trang. Và sở hữu trang thương mại điện tử về thời trang lớn nhất Việt Nam. Team công nghệ của HPI đang tìm kiếm đồng đội cho các vị trí như:

Với mục tiêu trong vòng 5 năm tới về mảng công nghệ là:

  • Sẽ có trang web nằm trong top 10 trang web nhanh nhất VN với 20 triệu lượt truy cập mỗi tháng.
  • 5 triệu loyal customers và có hơn 10 triệu transactions mỗi năm.

Team đang xây dựng một hệ thống rất lớn với rất nhiều vấn đề cần giải quyết, và sẽ có rất nhiều bài toán thú vị cho các bạn. Nếu các bạn có hứng thú trong việc xây dựng một hệ thống lớn, linh hoạt, dễ dàng mở rộng, và performance cao với kiến trúc microservices thì hãy tham gia với tụi mình.

Nếu các bạn quan tâm hãy gửi CV ở trong trang tuyển dụng của Hoàng Phúc International hoặc qua email của mình nha [email protected]. Cảm ơn các bạn đã đọc.

Nguồn: viblo.asia

Bài viết liên quan

WebP là gì? Hướng dẫn cách để chuyển hình ảnh jpg, png qua webp

WebP là gì? WebP là một định dạng ảnh hiện đại, được phát triển bởi Google

Điểm khác biệt giữa IPv4 và IPv6 là gì?

IPv4 và IPv6 là hai phiên bản của hệ thống địa chỉ Giao thức Internet (IP). IP l

Check nameservers của tên miền xem website trỏ đúng chưa

Tìm hiểu cách check nameservers của tên miền để xác định tên miền đó đang dùn

Mình đang dùng Google Domains để check tên miền hàng ngày

Từ khi thông báo dịch vụ Google Domains bỏ mác Beta, mình mới để ý và bắt đầ