Jump to content

Deploy app bare-metal

From Haidhi Wiki
Revision as of 06:42, 20 October 2026 by SanzSkuy (talk | contribs) (Created page with "Catatan tentang cara deploy aplikasi (Python FastAPI, Rust Actix, Node frontend) langsung di server Ubuntu (contoh: <code>marshall-3</code>) tanpa Docker, memakai '''uv''', '''systemd''', dan '''Ansible'''. Fokus utama catatan ini adalah sisi '''sysadmin''': layout direktori, model user dan permission, hardening service, dan otomasi. == Gambaran besar == Docker memberi tiga hal: '''isolasi''', '''reproducibility''', dan '''packaging'''. Di bare metal, ketiganya tetap b...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Catatan tentang cara deploy aplikasi (Python FastAPI, Rust Actix, Node frontend) langsung di server Ubuntu (contoh: marshall-3) tanpa Docker, memakai uv, systemd, dan Ansible. Fokus utama catatan ini adalah sisi sysadmin: layout direktori, model user dan permission, hardening service, dan otomasi.

Gambaran besar

Docker memberi tiga hal: isolasi, reproducibility, dan packaging. Di bare metal, ketiganya tetap bisa didapat dengan alat bawaan Linux:

Kebutuhan Di Docker Di bare metal
Isolasi proses namespace, cgroup user terpisah per app + hardening systemd (juga pakai namespace & cgroup)
Dependency terkunci image lock file (uv.lock, Cargo.lock, package-lock.json) + venv per release
Restart otomatis restart: unless-stopped Restart=on-failure di unit systemd
Log docker logs journalctl -u nama-app
Rollback tag image lama folder release lama + symlink
Deploy berulang compose / CI Ansible

Kapan bare metal masuk akal: sedikit server, workload GPU, tim kecil, atau binary tunggal (Rust/Go). Kapan Docker lebih unggul: banyak server, scaling horizontal, atau dependency sistem yang bentrok antar app (misalnya versi CUDA berbeda per app, karena di bare metal semua app berbagi satu driver NVIDIA).

Layout direktori (FHS)

Setiap app memakai lokasi standar Filesystem Hierarchy Standard, supaya jelas mana kode, mana config, mana data:

Path Isi Kenapa di sini
/opt/<app>/releases/<versi>/ kode + .venv / target / dist /opt untuk software tambahan yang bukan dari paket OS
/opt/<app>/current symlink ke release aktif service selalu menunjuk ke sini; ganti versi = ganti symlink
/etc/<app>/<app>.env environment variable & secret /etc untuk konfigurasi milik host, terpisah dari kode
/var/lib/<app>/ data, cache model, upload, cache uv/cargo/npm /var/lib untuk state yang ditulis aplikasi saat jalan
/opt/uv/python/ interpreter Python bersama dipakai semua app, bukan milik satu user
/etc/systemd/system/<app>.service unit service lokasi unit buatan admin (bukan dari paket)

Pemisahan ini penting untuk permission: kode boleh read-only saat runtime, config hanya root yang boleh ubah, dan hanya folder data yang boleh ditulis aplikasi.

Model user & permission

Satu app, satu system user

sudo useradd --system --home-dir /var/lib/transcribe-api \
  --shell /usr/sbin/nologin transcribe
  • --system: UID diambil dari rentang system (di Ubuntu di bawah 1000), tidak muncul di layar login, dan tidak dibuatkan mailbox atau aging password.
  • --shell /usr/sbin/nologin: user tidak bisa login interaktif. Kalau proses app dibobol, penyerang tidak otomatis dapat shell login.
  • Kenapa per app? Kalau satu app bocor, app lain dan file-nya tidak ikut terbaca. Ini prinsip least privilege.

Perintah berguna:

id transcribe                 # UID, GID, dan group tambahan
getent passwd transcribe      # home & shell
getent group | grep transcribe
sudo -u transcribe -H bash -c 'whoami; echo $HOME'   # tes sebagai user app

Salah nama user? Rename tanpa bikin ulang:

sudo usermod -l transcribe -d /var/lib/transcribe-api appuser   # ganti login & home
sudo groupmod -n transcribe appuser                            # ganti nama group

Karena UID/GID tidak berubah, ownership file otomatis ikut. usermod -d akan gagal kalau user sedang dipakai proses yang jalan, jadi stop service-nya dulu.

Error chown: invalid group: 'root:appuser' artinya group tersebut sudah tidak ada (sudah di-rename atau dihapus). Cek dengan getent group.

Tabel ownership & mode

Path Owner:Group Mode Artinya
/opt/<app> root:root 0755 hanya root yang bisa membuat/mengganti symlink current
/opt/<app>/releases app:app 0755 user app meng-clone dan build release di sini
/etc/<app> root:app 0750 app boleh masuk & baca, user lain tidak bisa melihat isinya
/etc/<app>/<app>.env root:app 0640 root tulis, app baca, lainnya tidak bisa apa-apa
/var/lib/<app> app:app 0750 satu-satunya tempat app boleh menulis
/var/lib/<app>/.ssh app:app 0700 deploy key; SSH menolak key yang permission-nya terlalu terbuka
/opt/uv/python root:root a+rX semua user boleh baca & eksekusi interpreter, tidak boleh ubah

Membaca mode oktal

Tiap digit = owner, group, others. Nilainya jumlah dari r=4, w=2, x=1.

Mode Owner Group Others Dipakai untuk
0755 rwx r-x r-x folder publik, binary
0750 rwx r-x --- folder privat yang boleh dibaca group
0700 rwx --- --- folder sangat privat (.ssh)
0640 rw- r-- --- file secret yang dibaca group
0600 rw- --- --- private key

Untuk folder, bit x artinya "boleh masuk (traverse)", bukan "eksekusi". Tanpa x di folder induk, file di dalamnya tidak bisa diakses walaupun file-nya sendiri r.

chmod -R a+rX (huruf X besar) memberi x hanya ke folder dan file yang sudah executable. Ini cara aman membuka akses baca satu pohon direktori tanpa membuat semua file teks jadi executable.

Kenapa env file root:app 0640?

  • App perlu membaca secret (DATABASE_URL, SECRET_KEY), tapi tidak perlu mengubahnya.
  • Kalau app dibobol, penyerang tidak bisa mengganti config agar persisten.
  • User lain di server tidak bisa membaca secret sama sekali.
  • Jangan simpan secret di .env dalam folder kode: gampang ikut ter-commit, dan ikut terbaca siapa pun yang bisa membaca kode.

Toolchain bersama & masalah home directory

uv bisa meng-install Python sendiri. Defaultnya ke ~/.local/share/uv/python. Masalahnya:

  • venv hanya symlink ke interpreter. Kalau interpreter ada di home user A, user B tidak bisa memakainya.
  • Unit systemd dengan ProtectHome=true membuat /home, /root, dan /run/user tidak terlihat. Interpreter di home = service gagal start.

Solusinya taruh di lokasi bersama dan buka akses baca:

export UV_PYTHON_INSTALL_DIR=/opt/uv/python
sudo -E uv python install 3.13
sudo chmod -R a+rX /opt/uv/python

Pola yang sama untuk Rust: RUSTUP_HOME=/opt/rust/rustup, CARGO_HOME=/opt/rust/cargo, lalu a+rX. Cache per app (registry cargo, cache uv, cache npm) ditaruh di /var/lib/<app> yang memang writable.

Akses GPU

Device NVIDIA ada di /dev/nvidia*:

ls -l /dev/nvidia*

Kalau mode-nya crw-rw-rw-, semua user bisa memakai GPU. Kalau hanya group video/render, tambahkan user app ke group tersebut (usermod -aG video transcribe) dan tulis SupplementaryGroups=video di unit. Opsi -a wajib; tanpa itu, group tambahan lain akan terhapus.

sudo yang sempit

Kalau deploy dijalankan sebagai user app dan perlu restart service, beri izin hanya untuk perintah itu:

echo 'transcribe ALL=(root) NOPASSWD: /usr/bin/systemctl restart transcribe-api, /usr/bin/systemctl restart transcribe-api-worker' \
  | sudo tee /etc/sudoers.d/transcribe-api
sudo chmod 0440 /etc/sudoers.d/transcribe-api
sudo visudo -cf /etc/sudoers.d/transcribe-api     # validasi sintaks!
  • Selalu validasi dengan visudo -c. File sudoers yang rusak bisa mengunci sudo untuk semua orang.
  • Path harus absolut dan persis sama. Di Ubuntu modern /bin adalah symlink ke /usr/bin, tapi sudo membandingkan string path, jadi cek dengan command -v systemctl.
  • Jangan beri NOPASSWD: ALL ke user app. Itu sama saja memberi root.

Kalau deploy dijalankan lewat Ansible dengan user admin + become, aturan sudoers untuk user app tidak diperlukan sama sekali.

Siapa yang boleh menulis kode?

Di setup ini user app yang meng-clone dan build release, jadi kode bisa ditulis oleh user app. Ini sederhana tapi berarti app yang dibobol bisa mengubah kodenya sendiri. Alternatif yang lebih ketat:

  • Buat user deployer terpisah yang meng-clone & build semua release.
  • User app hanya punya akses baca + eksekusi ke /opt/<app>.
  • Bonus: cache uv bisa dipakai bersama antar app, sehingga hardlink menghemat disk (berguna untuk paket besar seperti torch).

Pada level ini, ProtectSystem=strict sudah menahan sebagian besar risiko: saat runtime seluruh filesystem read-only bagi proses app, kecuali ReadWritePaths.

systemd

Anatomi unit

[Unit]
Description=Transcribe API
After=network-online.target redis-server.service
Wants=network-online.target redis-server.service

[Service]
Type=simple
User=transcribe
Group=transcribe
SupplementaryGroups=video
WorkingDirectory=/opt/transcribe-api/current
Environment=HOME=/var/lib/transcribe-api
Environment=PORT=6969
EnvironmentFile=-/etc/transcribe-api/transcribe-api.env
ExecStart=/opt/transcribe-api/current/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 6969
Restart=on-failure
RestartSec=5

NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/transcribe-api

[Install]
WantedBy=multi-user.target

Catatan per baris:

  • After= mengatur urutan, Wants=/Requires= mengatur ketergantungan. Hanya After= tidak membuat Redis ikut dijalankan.
  • EnvironmentFile=-...: tanda - artinya "jangan gagal kalau file tidak ada". Variabel dari file meng-override Environment=.
  • Format env file systemd: KEY=value per baris, tanpa export.
  • ExecStart harus path absolut. Panggil binary venv langsung, bukan uv run, karena uv run bisa mencoba sync dependency saat start.
  • Worker sebaiknya Restart=always dan TimeoutStopSec=120 supaya job yang sedang jalan sempat selesai saat restart (systemd mengirim SIGTERM dulu, baru SIGKILL setelah timeout).

Directive hardening

Directive Efek
NoNewPrivileges=true proses dan turunannya tidak bisa naik hak akses (setuid, file capabilities tidak berlaku)
ProtectSystem=strict seluruh filesystem read-only bagi service
ReadWritePaths= pengecualian: path yang tetap boleh ditulis
ProtectHome=true /home, /root, /run/user tidak terlihat
PrivateTmp=true /tmp privat per service, tidak berbagi dengan proses lain

Ukur tingkat keamanan unit:

systemd-analyze security transcribe-api

Output-nya memberi skor dan daftar directive lain yang bisa ditambahkan (misalnya ProtectKernelTunables, RestrictAddressFamilies). Tambahkan bertahap dan tes, karena hardening yang terlalu ketat bisa mematahkan app (misalnya akses GPU butuh device yang tidak diblokir PrivateDevices).

Operasional harian

sudo systemctl daemon-reload                     # wajib setelah edit file unit
sudo systemctl enable --now transcribe-api       # aktif saat boot + jalan sekarang
systemctl status transcribe-api transcribe-api-worker
journalctl -u transcribe-api -u transcribe-api-worker -f    # follow log
journalctl -u transcribe-api --since "1 hour ago" -p err    # hanya error
systemctl cat transcribe-api                      # lihat unit final yang dipakai

Exit code systemd yang sering muncul

Status Penyebab umum
status=203/EXEC path di ExecStart salah, file tidak executable, atau venv rusak (interpreter yang di-symlink tidak bisa diakses)
status=217/USER user di User= tidak ada
status=200/CHDIR WorkingDirectory tidak ada atau tidak bisa dimasuki user app
status=226/NAMESPACE path di ReadWritePaths belum dibuat
Permission denied di log app app menulis di luar ReadWritePaths (cache model, file upload); arahkan ke /var/lib/<app>

Pola release + symlink

/opt/transcribe-api/
├── releases/
│   ├── 3f2a9c1d7e0b/
│   └── 9be41c0a22f1/
└── current -> releases/3f2a9c1d7e0b

Alur deploy:

  1. Clone versi baru ke releases/<sha>.
  2. Build (uv sync --locked --no-dev, cargo build --release --locked, npm ci && npm run build).
  3. Jalankan migrasi database kalau ada.
  4. Arahkan current ke release baru, restart service.
  5. Health check. Gagal → arahkan kembali ke release lama.

Soal atomik: ln -sfn sebenarnya menghapus symlink lalu membuat ulang, sehingga ada jeda sangat singkat tanpa current. Cara yang benar-benar atomik:

ln -s /opt/app/releases/NEW /opt/app/current.tmp
mv -T /opt/app/current.tmp /opt/app/current     # rename(2) atomik

Venv tidak bisa dipindah. Path absolut tertanam di script dalam .venv/bin, jadi selalu build venv langsung di folder release, jangan build di tempat lain lalu di-copy.

Python dengan uv

  • uv sync --locked gagal kalau uv.lock tidak sinkron dengan pyproject.toml. Ini yang diinginkan di produksi: server tidak pernah mengubah lock file.
  • --no-dev melewati dependency development/test.
  • UV_PYTHON_DOWNLOADS=never mencegah uv diam-diam mengunduh Python saat build.
  • Update dependency dilakukan di laptop: uv lock --upgrade, commit, lalu deploy.
  • Cache uv memakai hardlink ke venv selama berada di filesystem yang sama, sehingga deploy berikutnya cepat dan hemat disk.

Redis lokal

sudo apt install -y redis-server
sudo sed -i 's/^appendonly no/appendonly yes/' /etc/redis/redis.conf   # persistence
grep -E '^(bind|appendonly)' /etc/redis/redis.conf                     # bind 127.0.0.1 -::1
sudo systemctl enable --now redis-server
redis-cli ping

Redis tanpa password wajib hanya listen di localhost. Di Docker, hostname-nya redis; di bare metal jadi redis://127.0.0.1:6379/0.

Jaringan

  • --host 127.0.0.1: app hanya bisa diakses dari server itu sendiri (cocok kalau nginx ada di server yang sama).
  • --host 0.0.0.0: app bisa diakses dari luar. Wajib dibarengi firewall.
  • Kalau nginx publik ada di VM proxy terpisah, buka port app hanya untuk IP VM proxy:
sudo ufw allow OpenSSH
sudo ufw allow from 192.168.88.10 to any port 6969 proto tcp comment 'transcribe-api'
sudo ufw enable
sudo ufw status numbered

Ubuntu memakai AppArmor, bukan SELinux seperti AlmaLinux/RHEL. Untuk app di /opt biasanya tidak perlu konfigurasi tambahan.

Otomasi dengan Ansible

Kenapa Ansible

  • Agentless: cukup SSH + Python di server.
  • Idempotent: dijalankan berkali-kali hasilnya sama; hanya yang berubah yang diubah.
  • Semua langkah manual di atas terdokumentasi sebagai kode, sehingga server bisa dibangun ulang kapan saja.

Struktur project

ansible-apps/
├── inventory.ini            daftar server
├── apps.yml                 daftar app (repo, port, worker)
├── deploy.yml               playbook utama
├── group_vars/all/main.yml  setting global
├── group_vars/all/vault.yml secret terenkripsi
├── scripts/new-app.sh       tambah app interaktif
└── roles/
    ├── base/                paket dasar
    ├── redis/
    ├── python_toolchain/    uv + Python di /opt/uv/python
    ├── rust_toolchain/      rustup di /opt/rust
    ├── node_toolchain/      NodeSource
    └── app/                 clone, deteksi tipe, build, systemd/nginx, health check, rollback

Entry app

apps:
  - name: transcribe-api
    repo: [email protected]:org/transcribe-api.git
    version: main
    port: 6969
    user: transcribe
    worker: true          # bikin service transcribe-api-worker
    redis: true
    apt_packages: [ffmpeg]
    extra_groups: [video]
    health_path: /health

Tipe app dideteksi dari isi repo: pyproject.toml → Python, Cargo.toml → Rust, package.json → Node (Vite/React jadi static di-serve nginx, Next/Nuxt jadi service).

Perintah

ansible-playbook deploy.yml                                   # semua app
ansible-playbook deploy.yml -e only=transcribe-api            # satu app
ansible-playbook deploy.yml -e only=core-api -e rev=v1.2.0    # versi tertentu / rollback
ansible-playbook deploy.yml --syntax-check
ansible-vault encrypt group_vars/all/vault.yml

Konsep Ansible yang terkait permission

  • become: true: task dijalankan sebagai root lewat sudo.
  • become_user: transcribe: task dijalankan sebagai user app (clone, build), sehingga file yang dihasilkan langsung dimiliki user yang benar tanpa chown belakangan.
  • Paket acl wajib terpasang. Saat become_user ke user non-root, Ansible menulis file modul sementara lalu memakai ACL (setfacl) agar user tujuan bisa membacanya.
  • Modul file dengan owner, group, mode menggantikan rangkaian mkdir/chown/chmod dan idempotent.
  • Secret di ansible-vault, dirender ke env file lewat modul template dengan mode 0640.

Deploy key untuk repo private

  • Satu key per app, disimpan di /var/lib/<app>/.ssh/id_ed25519 (mode 0600, folder 0700).
  • Didaftarkan sebagai Deploy Key read-only di repo. Kalau server bocor, yang terekspos hanya akses baca ke satu repo, bukan akun GitHub pribadi.
  • StrictHostKeyChecking=accept-new: host key disimpan saat pertama kali, dan koneksi ditolak kalau host key berubah (proteksi MITM).

Troubleshooting

Gejala Penyebab Solusi
chown: invalid group group sudah di-rename/dihapus getent group, pakai nama baru
useradd menghasilkan shell aneh sisa \ line-continuation ikut diketik dalam satu baris cek getent passwd, bikin ulang user
Service 203/EXEC padahal file ada venv menunjuk ke Python di home user lain install Python di /opt/uv/python, buat ulang venv
Model Whisper gagal diunduh cache di luar ReadWritePaths set WHISPER_CACHE_DIR / HF_HOME ke /var/lib/<app>
libcudnn / libcublas not found di Docker library datang dari image install paket pip nvidia-cudnn-cu12 / nvidia-cublas-cu12 atau CUDA toolkit di host
Worker restart terus error di job pertama atau Redis belum jalan journalctl -u app-worker -n 100, redis-cli ping
Ansible gagal become_user paket acl belum terpasang apt install acl

Checklist server baru

  • [ ] User admin pakai SSH key, login password dimatikan
  • [ ] ufw aktif, hanya SSH + port yang perlu
  • [ ] Driver NVIDIA terpasang, nvidia-smi jalan (kalau ada GPU)
  • [ ] unattended-upgrades aktif untuk patch keamanan
  • [ ] Jalankan ansible-playbook deploy.yml
  • [ ] systemd-analyze security <app> untuk tiap service
  • [ ] Backup data di /var/lib/<app> dan database, tes restore

Lihat juga