Dựng Sáu Máy EC2 và Chuẩn Bị Hệ Điều Hành

K
Kai··12 min read

Hai bài trước là lý thuyết. Từ bài này ta bắt đầu chạm tay vào hạ tầng. Mục tiêu hôm nay gọn nhưng là nền cho mọi bài sau: dựng sáu máy ảo trên AWS, đặt chúng trong một mạng riêng với địa chỉ cố định, rồi chuẩn bị hệ điều hành để chúng sẵn sàng chạy các thành phần Kubernetes. Chưa cài Kubernetes gì cả — chỉ đất nền và móng.

Mọi lệnh dưới đây tôi chạy thật trên một account AWS, region ap-southeast-1 (Singapore), và output là output thật. Bạn làm theo trên account của mình, đổi region nếu muốn.

💰 Chi phí

Sáu instance chạy on-demand ở ap-southeast-1:

   lb-0          t3.small   (2 vCPU / 2GB)   ~$0.026/giờ
   controller-0..2  t3.medium (2 vCPU / 4GB)  ~$0.053/giờ × 3
   worker-0..1   t3.medium  (2 vCPU / 4GB)   ~$0.053/giờ × 2
   ───────────────────────────────────────────────────────
   tổng tính tròn                            ~$0.29/giờ

Cộng vài chục GB EBS gp3 (không đáng kể theo giờ). Cả series nếu làm rải vài buổi, nhớ stop instance khi nghỉ (mục Dọn dẹp cuối bài) — lúc stopped không tính tiền compute, chỉ còn phí EBS rất nhỏ.

Hạ tầng ta sắp dựng

Một VPC riêng 10.0.0.0/16, một public subnet 10.0.1.0/24, và sáu máy với IP private đặt cố định để về sau viết cấu hình cho dễ nhớ:

   VPC 10.0.0.0/16
   └── subnet 10.0.1.0/24 (public, ap-southeast-1a)
        ├── 10.0.1.10  lb-0          (HAProxy, sau này)
        ├── 10.0.1.11  controller-0  ┐
        ├── 10.0.1.12  controller-1  ├ control plane (etcd + apiserver + ...)
        ├── 10.0.1.13  controller-2  ┘
        ├── 10.0.1.20  worker-0      ┐
        └── 10.0.1.21  worker-1      ┘ nơi chạy pod

Đặt IP cố định quan trọng hơn vẻ ngoài của nó: certificate ở Bài 4 sẽ nhúng đúng các IP này vào trường SAN, file cấu hình etcd sẽ trỏ tới đúng 10.0.1.11..13. Nếu IP nhảy mỗi lần khởi động lại, cả cluster sẽ vỡ. Trong subnet của VPC, một instance giữ nguyên IP private suốt vòng đời, nên ta chỉ cần chỉ định lúc tạo.

Bước 1 — Chuẩn bị AWS CLI và chọn AMI

Bài này điều khiển AWS bằng aws CLI từ máy của bạn. Giả sử bạn đã cấu hình credentials (aws configure) và có quyền tạo VPC/EC2. Đặt sẵn region cho gọn:

export AWS_REGION=ap-southeast-1

Tìm AMI Ubuntu 24.04 LTS (amd64) mới nhất. Canonical công bố ID này qua SSM Parameter Store, nên không phải dò tay:

aws ssm get-parameter \
  --name /aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id \
  --query 'Parameter.Value' --output text
ami-04592e28abc2b9fc9

Lấy ID đó ra biến để dùng lại:

export AMI=ami-04592e28abc2b9fc9

Bước 2 — Tạo VPC, Internet Gateway, subnet, route table

Ta không dùng VPC mặc định mà tạo một VPC riêng. Lý do: cô lập gọn gàng, và dễ xóa sạch ở cuối series (xóa VPC là cuốn theo mọi thứ bên trong). Tạo VPC và bật DNS hostname:

VPC_ID=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
  --tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=k8s-scratch-vpc},{Key=Project,Value=k8s-from-scratch}]' \
  --query 'Vpc.VpcId' --output text)
aws ec2 modify-vpc-attribute --vpc-id $VPC_ID --enable-dns-hostnames '{"Value":true}'
echo $VPC_ID
vpc-0b0f8006d8d9c0c08

Để ý cái tag Project=k8s-from-scratch gắn vào mọi tài nguyên — cuối series ta lọc theo tag này để dọn, khỏi sót cái nào.

Internet Gateway cho các máy ra Internet (kéo gói cài đặt, tải binary), rồi attach vào VPC:

IGW_ID=$(aws ec2 create-internet-gateway \
  --tag-specifications 'ResourceType=internet-gateway,Tags=[{Key=Project,Value=k8s-from-scratch}]' \
  --query 'InternetGateway.InternetGatewayId' --output text)
aws ec2 attach-internet-gateway --vpc-id $VPC_ID --internet-gateway-id $IGW_ID

Subnet 10.0.1.0/24 trong AZ ap-southeast-1a, bật tự gán public IP để máy nào tạo trong đó cũng có IP công khai mà ta SSH vào:

SUBNET_ID=$(aws ec2 create-subnet --vpc-id $VPC_ID \
  --cidr-block 10.0.1.0/24 --availability-zone ${AWS_REGION}a \
  --tag-specifications 'ResourceType=subnet,Tags=[{Key=Project,Value=k8s-from-scratch}]' \
  --query 'Subnet.SubnetId' --output text)
aws ec2 modify-subnet-attribute --subnet-id $SUBNET_ID --map-public-ip-on-launch

Cuối cùng route table: thêm một route mặc định 0.0.0.0/0 trỏ ra Internet Gateway, rồi gắn route table vào subnet:

RTB_ID=$(aws ec2 create-route-table --vpc-id $VPC_ID \
  --tag-specifications 'ResourceType=route-table,Tags=[{Key=Project,Value=k8s-from-scratch}]' \
  --query 'RouteTable.RouteTableId' --output text)
aws ec2 create-route --route-table-id $RTB_ID --destination-cidr-block 0.0.0.0/0 --gateway-id $IGW_ID
aws ec2 associate-route-table --route-table-id $RTB_ID --subnet-id $SUBNET_ID

Route table này còn một vai trò nữa ở chặng mạng pod (Bài 14): ta sẽ thêm route cho dải pod của từng worker vào đây. Giờ chỉ cần nó cho phép ra Internet là đủ.

Bước 3 — Security Group và key pair

Security Group là tường lửa ở mức instance. Ta cần ba luật vào (inbound):

  • SSH (22) từ IP của bạn để vào quản trị.
  • 6443 từ IP của bạn để sau này kubectl gọi tới load balancer.
  • Mọi traffic giữa các máy cùng group — để các thành phần cluster (etcd, apiserver, kubelet, mạng pod) nói chuyện thoải mái với nhau.
SG_ID=$(aws ec2 create-security-group --group-name k8s-scratch-sg \
  --description "k8s from scratch lab" --vpc-id $VPC_ID \
  --tag-specifications 'ResourceType=security-group,Tags=[{Key=Project,Value=k8s-from-scratch}]' \
  --query 'GroupId' --output text)

MYIP=$(curl -s https://checkip.amazonaws.com)   # IP công khai của máy bạn

# SSH + apiserver từ IP của bạn
aws ec2 authorize-security-group-ingress --group-id $SG_ID \
  --ip-permissions \
    "IpProtocol=tcp,FromPort=22,ToPort=22,IpRanges=[{CidrIp=${MYIP}/32,Description=ssh}]" \
    "IpProtocol=tcp,FromPort=6443,ToPort=6443,IpRanges=[{CidrIp=${MYIP}/32,Description=kube-apiserver}]"

# Mọi traffic nội bộ giữa các thành viên cùng SG
aws ec2 authorize-security-group-ingress --group-id $SG_ID \
  --ip-permissions "IpProtocol=-1,UserIdGroupPairs=[{GroupId=${SG_ID},Description=intra-cluster}]"

Ở đây MYIP của tôi là 203.0.113.45 (đã thay bằng IP ví dụ). Của bạn sẽ khác; nếu mạng nhà đổi IP, nhớ cập nhật lại luật SSH, không thì bị khóa ngoài.

Cho phép "mọi traffic nội bộ" trong một lab là chấp nhận được và đỡ phải mở từng cổng (etcd 2379/2380, apiserver 6443, kubelet 10250, các cổng pod...). Trên production thì ta siết lại từng cổng, nhưng đó là chuyện khác.

Tạo key pair để SSH, lưu private key về máy với quyền chặt:

mkdir -p ~/k8s-scratch && cd ~/k8s-scratch
aws ec2 create-key-pair --key-name k8s-scratch \
  --query 'KeyMaterial' --output text > k8s-scratch.pem
chmod 600 k8s-scratch.pem

Bước 4 — Launch sáu instance

Giờ tạo máy. Mỗi instance được chỉ định một IP private cố định (--private-ip-address) và một root volume 20GB gp3. Một chi tiết dễ bỏ sót: ngay sau khi tạo, ta tắt source/destination check trên mỗi máy. EC2 mặc định chặn một instance chuyển tiếp gói không phải của nó; nhưng ở Bài 14, các node sẽ định tuyến traffic của pod cho nhau, nên phải tắt kiểm tra này từ giờ.

Một hàm nhỏ cho gọn, rồi gọi sáu lần:

launch() {  # tên  ip  loại
  local id=$(aws ec2 run-instances \
    --image-id $AMI --instance-type $3 --key-name k8s-scratch \
    --subnet-id $SUBNET_ID --security-group-ids $SG_ID \
    --private-ip-address $2 \
    --block-device-mappings 'DeviceName=/dev/sda1,Ebs={VolumeSize=20,VolumeType=gp3}' \
    --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=$1},{Key=Project,Value=k8s-from-scratch}]" \
    --query 'Instances[0].InstanceId' --output text)
  echo "$1 ($2, $3) -> $id"
  aws ec2 modify-instance-attribute --instance-id $id --no-source-dest-check
}

launch lb-0         10.0.1.10 t3.small
launch controller-0 10.0.1.11 t3.medium
launch controller-1 10.0.1.12 t3.medium
launch controller-2 10.0.1.13 t3.medium
launch worker-0     10.0.1.20 t3.medium
launch worker-1     10.0.1.21 t3.medium
lb-0 (10.0.1.10, t3.small) -> i-01e955f527ff25a57
controller-0 (10.0.1.11, t3.medium) -> i-05d8b7584a933394a
controller-1 (10.0.1.12, t3.medium) -> i-0ee0d05a3f68f2b73
controller-2 (10.0.1.13, t3.medium) -> i-07d62211877ed360b
worker-0 (10.0.1.20, t3.medium) -> i-0f1ab7628507cb9cd
worker-1 (10.0.1.21, t3.medium) -> i-0a33782c408f5bf09

Chờ tất cả vào trạng thái running rồi xem bảng IP:

aws ec2 wait instance-running \
  --filters Name=tag:Project,Values=k8s-from-scratch

aws ec2 describe-instances \
  --filters Name=tag:Project,Values=k8s-from-scratch Name=instance-state-name,Values=running \
  --query 'Reservations[].Instances[].[Tags[?Key==`Name`]|[0].Value, PrivateIpAddress, PublicIpAddress, InstanceType]' \
  --output table
--------------------------------------------------------------
|                      DescribeInstances                     |
+---------------+------------+------------------+------------+
|  controller-1 |  10.0.1.12 |  203.0.113.12   |  t3.medium |
|  lb-0         |  10.0.1.10 |  47.129.155.41   |  t3.small  |
|  worker-1     |  10.0.1.21 |  203.0.113.21  |  t3.medium |
|  controller-2 |  10.0.1.13 |  203.0.113.13  |  t3.medium |
|  worker-0     |  10.0.1.20 |  203.0.113.20  |  t3.medium |
|  controller-0 |  10.0.1.11 |  203.0.113.11  |  t3.medium |
+---------------+------------+------------------+------------+

Private IP đúng như ta đặt; public IP do AWS cấp (của bạn sẽ khác, và đổi mỗi lần stop/start — ta chỉ dùng nó để SSH).

Một Elastic IP cố định cho lb-0

kubectl từ máy bạn sẽ gọi vào api-server qua public IP của lb-0. Public IP tự cấp của EC2 đổi mỗi lần stop/start, mà địa chỉ này lại phải được nhúng cố định vào certificate của api-server (Bài 4). Để khỏi vỡ về sau, ta gán cho lb-0 một Elastic IP — một public IP cố định, giữ nguyên qua stop/start:

LB_ID=$(aws ec2 describe-instances \
  --filters Name=tag:Name,Values=lb-0 Name=instance-state-name,Values=running \
  --query 'Reservations[0].Instances[0].InstanceId' --output text)

EIP_ALLOC=$(aws ec2 allocate-address --domain vpc \
  --tag-specifications 'ResourceType=elastic-ip,Tags=[{Key=Project,Value=k8s-from-scratch}]' \
  --query 'AllocationId' --output text)
aws ec2 associate-address --instance-id $LB_ID --allocation-id $EIP_ALLOC

aws ec2 describe-addresses --allocation-ids $EIP_ALLOC --query 'Addresses[0].PublicIp' --output text
203.0.113.10

Ghi nhớ Elastic IP này (ở đây là 203.0.113.10) — Bài 4 sẽ đưa nó vào SAN của cert api-server, và đây cũng là địa chỉ kubectl trỏ tới. Sau khi gán Elastic IP, public IP của lb-0 đổi sang chính IP này.

Bước 5 — Cấu hình SSH cho tiện cả series

Cả series sẽ SSH vào sáu máy này rất nhiều lần. Thay vì gõ -i key.pem ubuntu@<ip> mỗi lần, ta viết một file SSH config riêng (không đụng ~/.ssh/config của hệ thống), điền public IP vừa lấy ở trên:

cat > ~/k8s-scratch/ssh_config <<'EOF'
Host *
  User ubuntu
  IdentityFile ~/k8s-scratch/k8s-scratch.pem
  IdentitiesOnly yes
  StrictHostKeyChecking no
  UserKnownHostsFile /dev/null
  LogLevel ERROR

Host lb-0          
  HostName 203.0.113.10
Host controller-0  
  HostName 203.0.113.11
Host controller-1  
  HostName 203.0.113.12
Host controller-2  
  HostName 203.0.113.13
Host worker-0      
  HostName 203.0.113.20
Host worker-1      
  HostName 203.0.113.21
EOF

IdentitiesOnly yes đáng để ý: nếu máy bạn có ssh-agent đang giữ nhiều key khác, ssh sẽ chào lần lượt các key đó trước và có thể bị server từ chối vì quá số lần thử, trước cả khi tới đúng key của ta. Dòng này ép ssh chỉ dùng đúng IdentityFile đã khai. Thử vào controller-0:

ssh -F ~/k8s-scratch/ssh_config controller-0 'hostname; lsb_release -ds; uname -r'
ip-10-0-1-11
Ubuntu 24.04.3 LTS
6.14.0-1018-aws

(Hostname còn là tên mặc định ip-10-0-1-11 — ta sửa ngay bước sau.)

Bước 6 — Chuẩn bị hệ điều hành

Có hai nhóm việc OS. Nhóm thứ nhất áp cho cả sáu máy: đặt hostname và một file /etc/hosts chung để các máy gọi nhau bằng tên. Nhóm thứ hai chỉ cho năm node Kubernetes (controller + worker): nạp kernel module, chỉnh sysctl, tắt swap. Máy lb-0 chỉ chạy HAProxy nên không cần nhóm hai.

Hostname và /etc/hosts (cả sáu máy)

HOSTS_BLOCK='10.0.1.10 lb-0
10.0.1.11 controller-0
10.0.1.12 controller-1
10.0.1.13 controller-2
10.0.1.20 worker-0
10.0.1.21 worker-1'

for host in lb-0 controller-0 controller-1 controller-2 worker-0 worker-1; do
  echo "== $host =="
  ssh -F ~/k8s-scratch/ssh_config $host "sudo hostnamectl set-hostname $host && \
    printf '%s\n' '$HOSTS_BLOCK' | sudo tee /etc/k8s-hosts >/dev/null && \
    ( echo '# k8s-scratch'; cat /etc/k8s-hosts ) | sudo tee -a /etc/hosts >/dev/null && \
    echo hostname=\$(hostname)"
done
== lb-0 ==
hostname=lb-0
== controller-0 ==
hostname=controller-0
== controller-1 ==
hostname=controller-1
== controller-2 ==
hostname=controller-2
== worker-0 ==
hostname=worker-0
== worker-1 ==
hostname=worker-1

Giờ từ bất kỳ máy nào, ping controller-1 hay ping worker-0 đều ra IP private đúng.

Kernel module, sysctl, swap (năm node k8s)

Ba việc này là yêu cầu nền của Kubernetes, và đây là lý do từng việc:

  • Module overlay: containerd dùng overlay filesystem để xếp lớp image container.
  • Module br_netfilter + sysctl bridge-nf-call-iptables: cho phép traffic đi qua Linux bridge (mạng pod) hiện ra trước iptables, để luật của kube-proxy áp được lên gói của pod.
  • net.ipv4.ip_forward=1: bật chuyển tiếp gói giữa các interface — bắt buộc để node định tuyến traffic pod.
  • Tắt swap: kubelet mặc định từ chối chạy khi còn swap, vì swap làm rối việc quản lý bộ nhớ và QoS của pod.

Chạy trên năm node k8s:

for host in controller-0 controller-1 controller-2 worker-0 worker-1; do
  echo "== $host =="
  ssh -F ~/k8s-scratch/ssh_config $host 'bash -s' <<'PREP'
set -e
# Kernel modules
printf 'overlay\nbr_netfilter\n' | sudo tee /etc/modules-load.d/k8s.conf >/dev/null
sudo modprobe overlay && sudo modprobe br_netfilter

# sysctl
cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf >/dev/null
net.bridge.bridge-nf-call-iptables  = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward                 = 1
EOF
sudo sysctl --system >/dev/null

# Tắt swap
sudo swapoff -a
sudo sed -i '/\bswap\b/s/^/#/' /etc/fstab 2>/dev/null || true

# Kiểm chứng
echo "modules: $(lsmod | grep -E '^(overlay|br_netfilter)' | awk '{print $1}' | sort | tr '\n' ' ')"
echo "ip_forward=$(cat /proc/sys/net/ipv4/ip_forward) bridge-nf=$(cat /proc/sys/net/bridge/bridge-nf-call-iptables)"
echo "swap: $(free -h | awk '/Swap/{print $2}')"
PREP
done
== controller-0 ==
modules: br_netfilter overlay 
ip_forward=1 bridge-nf=1
swap: 0B
== controller-1 ==
modules: br_netfilter overlay 
ip_forward=1 bridge-nf=1
swap: 0B
== controller-2 ==
...
swap: 0B

Việc đặt module vào /etc/modules-load.d/ và sysctl vào /etc/sysctl.d/ đảm bảo chúng vẫn còn sau khi rebootmodprobesysctl thủ công chỉ áp cho phiên hiện tại. (Ubuntu trên EC2 vốn không bật swap, nên swap: 0B là đúng kỳ vọng; bước swapoff ở đây để chắc chắn và để bạn quen thao tác.)

Bước 7 — Cài công cụ trên workstation

Việc sinh certificate (Bài 4) và chạy kubectl ta làm từ máy của mình (workstation), rồi đẩy file lên các node. Cần hai thứ: kubectl đúng phiên bản cluster, và cfssl cùng cfssljson của CloudFlare để tạo PKI.

kubectl ghim đúng v1.36.1 cho khớp cluster (đổi darwin/arm64 thành linux/amd64 nếu workstation của bạn là Linux):

cd ~/k8s-scratch
curl -sLO https://dl.k8s.io/release/v1.36.1/bin/darwin/arm64/kubectl
chmod +x kubectl
./kubectl version --client
Client Version: v1.36.1
Kustomize Version: v5.8.1

cfsslcfssljson — trên macOS cài bằng Homebrew, trên Linux tải binary từ GitHub của cfssl:

# macOS
brew install cfssl

# hoặc Linux:
# curl -sL -o /usr/local/bin/cfssl     https://github.com/cloudflare/cfssl/releases/download/v1.6.5/cfssl_1.6.5_linux_amd64
# curl -sL -o /usr/local/bin/cfssljson https://github.com/cloudflare/cfssl/releases/download/v1.6.5/cfssljson_1.6.5_linux_amd64
# chmod +x /usr/local/bin/cfssl /usr/local/bin/cfssljson

cfssl version
Version: 1.6.5
Runtime: go1.25.1

Vậy là workstation đã đủ đồ nghề. Bài 4 ta sẽ ngồi xuống dùng cfssl ký toàn bộ certificate trong sơ đồ PKI của Bài 2.

🧹 Dọn dẹp

Series này kéo dài nhiều bài, nên đừng xóa hạ tầng giữa chừng. Nhưng để khỏi tốn tiền lúc bạn nghỉ giữa các buổi, hãy stop các instance — lúc stopped không mất phí compute, chỉ còn phí EBS rất nhỏ:

# Lấy danh sách instance id theo tag rồi stop tất cả
IDS=$(aws ec2 describe-instances \
  --filters Name=tag:Project,Values=k8s-from-scratch Name=instance-state-name,Values=running \
  --query 'Reservations[].Instances[].InstanceId' --output text)
aws ec2 stop-instances --instance-ids $IDS

Khi quay lại học tiếp, start-instances rồi cập nhật lại public IP trong ssh_config (private IP giữ nguyên, nên cấu hình cluster không ảnh hưởng):

aws ec2 start-instances --instance-ids $IDS

Việc xóa sạch toàn bộ (instance, VPC, IGW, subnet, SG, key pair) để dành cho Bài 23 — khi đó ta lọc theo tag Project=k8s-from-scratch và gỡ tất cả.

Tổng kết

Ta đã có một mạng riêng với sáu máy địa chỉ cố định, hệ điều hành đã chỉnh đúng cho Kubernetes, và workstation đủ công cụ. Hạ tầng tới đây là "trống" — chưa có một thành phần Kubernetes nào. Đó là chủ đích: ta muốn đặt từng viên gạch một cách có ý thức.

Bài 4 là bước đầu tiên thực sự "Kubernetes": dùng cfssl tạo ba CA và toàn bộ certificate cho từng thành phần — apiserver, từng kubelet, controller-manager, scheduler, kube-proxy, client cho etcd — đúng từng trường CN/O và SAN như đã bàn ở Bài 2. Đây là phần kubeadm giấu kín, và là phần đáng giá nhất để làm bằng tay.

Related Posts