Beberapa tip tentang cara mempercepat pembuatan image Docker. Misalnya hingga 30 detik

Sebelum sebuah fitur mulai diproduksi, di era orkestrator dan CI/CD yang kompleks, masih ada jalan panjang yang harus ditempuh mulai dari komitmen hingga pengujian dan pengiriman. Sebelumnya, Anda dapat mengunggah file baru melalui FTP (tidak ada lagi yang melakukannya, kan?), dan proses β€œpenerapan” memakan waktu beberapa detik. Sekarang Anda perlu membuat permintaan penggabungan dan menunggu lama hingga fitur tersebut menjangkau pengguna.

Bagian dari jalur ini adalah membangun image Docker. Terkadang kebaktian berlangsung beberapa menit, terkadang puluhan menit, yang hampir tidak bisa disebut normal. Pada artikel ini, kita akan mengambil aplikasi sederhana yang akan kita paketkan ke dalam sebuah gambar, menerapkan beberapa metode untuk mempercepat pembangunan, dan melihat perbedaan cara kerja metode ini.

Beberapa tip tentang cara mempercepat pembuatan image Docker. Misalnya hingga 30 detik

Kami memiliki pengalaman yang baik dalam membuat dan mendukung situs web media: TASS, Bell, "Koran Baru", Republik… Belum lama ini kami memperluas portofolio kami dengan merilis website produk Reminder. Dan meskipun fitur-fitur baru ditambahkan dengan cepat dan bug lama diperbaiki, penerapan yang lambat menjadi masalah besar.

Kami menyebarkan ke GitLab. Kami mengumpulkan gambar, memasukkannya ke GitLab Registry, dan meluncurkannya ke produksi. Hal terpanjang dalam daftar ini adalah merakit gambar. Misalnya: tanpa pengoptimalan, setiap pembuatan backend memerlukan waktu 14 menit.

Beberapa tip tentang cara mempercepat pembuatan image Docker. Misalnya hingga 30 detik

Pada akhirnya, menjadi jelas bahwa kami tidak bisa lagi hidup seperti ini, dan kami duduk untuk mencari tahu mengapa pengumpulan gambar membutuhkan waktu begitu lama. Hasilnya, kami berhasil mengurangi waktu perakitan menjadi 30 detik!

Beberapa tip tentang cara mempercepat pembuatan image Docker. Misalnya hingga 30 detik

Untuk artikel ini, agar tidak terikat dengan lingkungan Reminder, mari kita lihat contoh perakitan aplikasi Angular yang kosong. Jadi, mari buat aplikasi kita:

ng n app

Tambahkan PWA ke dalamnya (kami progresif):

ng add @angular/pwa --project app

Saat jutaan paket npm sedang diunduh, mari kita cari tahu cara kerja image buruh pelabuhan. Docker menyediakan kemampuan untuk mengemas aplikasi dan menjalankannya di lingkungan terisolasi yang disebut container. Berkat isolasi, Anda dapat menjalankan banyak container secara bersamaan di satu server. Kontainer jauh lebih ringan dibandingkan mesin virtual karena dijalankan langsung pada kernel sistem. Untuk menjalankan container dengan aplikasi kita, pertama-tama kita perlu membuat image yang akan mengemas semua yang diperlukan agar aplikasi kita dapat berjalan. Pada dasarnya, gambar adalah salinan dari sistem file. Misalnya, ambil Dockerfile:

FROM node:12.16.2
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build --prod

Dockerfile adalah sekumpulan instruksi; Dengan melakukan masing-masingnya, Docker akan menyimpan perubahan pada sistem file dan melapisinya dengan perubahan sebelumnya. Setiap tim membuat lapisannya sendiri. Dan gambar yang sudah jadi adalah lapisan yang digabungkan menjadi satu.

Yang penting untuk diketahui: setiap lapisan Docker dapat melakukan cache. Jika tidak ada yang berubah sejak build terakhir, alih-alih menjalankan perintah, buruh pelabuhan akan mengambil lapisan yang sudah jadi. Karena peningkatan utama dalam kecepatan build disebabkan oleh penggunaan cache, saat mengukur kecepatan build, kami akan memberikan perhatian khusus pada pembuatan image dengan cache yang sudah jadi. Jadi, langkah demi langkah:

  1. Kami menghapus gambar secara lokal sehingga proses sebelumnya tidak mempengaruhi pengujian.
    docker rmi $(docker images -q)
  2. Kami meluncurkan build untuk pertama kalinya.
    time docker build -t app .
  3. Kami mengubah file src/index.html - kami meniru pekerjaan seorang programmer.
  4. Kami menjalankan pembangunan untuk kedua kalinya.
    time docker build -t app .

Jika lingkungan untuk membuat image dikonfigurasi dengan benar (lebih lanjut tentang itu di bawah), maka saat build dimulai, Docker sudah memiliki banyak cache. Tugas kita adalah mempelajari cara menggunakan cache agar pembangunan berjalan secepat mungkin. Karena kita berasumsi bahwa menjalankan build tanpa cache hanya terjadi sekaliβ€”pertama kaliβ€”kita dapat mengabaikan betapa lambatnya build pertama tersebut. Dalam pengujian, proses build yang kedua penting bagi kami, saat cache sudah dihangatkan dan kami siap membuat kue. Namun, beberapa tips juga akan mempengaruhi build pertama.

Mari kita letakkan Dockerfile yang dijelaskan di atas ke dalam folder proyek dan mulai membangun. Semua daftar telah diringkas untuk kemudahan membaca.

$ time docker build -t app .
Sending build context to Docker daemon 409MB
Step 1/5 : FROM node:12.16.2
Status: Downloaded newer image for node:12.16.2
Step 2/5 : WORKDIR /app
Step 3/5 : COPY . .
Step 4/5 : RUN npm ci
added 1357 packages in 22.47s
Step 5/5 : RUN npm run build --prod
Date: 2020-04-16T19:20:09.664Z - Hash: fffa0fddaa3425c55dd3 - Time: 37581ms
Successfully built c8c279335f46
Successfully tagged app:latest

real 5m4.541s
user 0m0.000s
sys 0m0.000s

Kami mengubah konten src/index.html dan menjalankannya untuk kedua kalinya.

$ time docker build -t app .
Sending build context to Docker daemon 409MB
Step 1/5 : FROM node:12.16.2
Step 2/5 : WORKDIR /app
 ---> Using cache
Step 3/5 : COPY . .
Step 4/5 : RUN npm ci
added 1357 packages in 22.47s
Step 5/5 : RUN npm run build --prod
Date: 2020-04-16T19:26:26.587Z - Hash: fffa0fddaa3425c55dd3 - Time: 37902ms
Successfully built 79f335df92d3
Successfully tagged app:latest

real 3m33.262s
user 0m0.000s
sys 0m0.000s

Untuk melihat apakah kita memiliki gambar tersebut, jalankan perintah docker images:

REPOSITORY   TAG      IMAGE ID       CREATED              SIZE
app          latest   79f335df92d3   About a minute ago   1.74GB

Sebelum membangun, buruh pelabuhan mengambil semua file dalam konteks saat ini dan mengirimkannya ke daemonnya Sending build context to Docker daemon 409MB. Konteks build ditentukan sebagai argumen terakhir pada perintah build. Dalam kasus kami, ini adalah direktori saat ini - β€œ.”, - dan Docker menyeret semua yang kami miliki ke folder ini. 409 MB itu banyak: mari pikirkan cara memperbaikinya.

Mengurangi konteksnya

Untuk mengurangi konteksnya, ada dua pilihan. Atau letakkan semua file yang diperlukan untuk perakitan di folder terpisah dan arahkan konteks buruh pelabuhan ke folder ini. Hal ini mungkin tidak selalu nyaman, sehingga dimungkinkan untuk menentukan pengecualian: apa yang tidak boleh dimasukkan ke dalam konteks. Untuk melakukan ini, masukkan file .dockerignore ke dalam proyek dan tunjukkan apa yang tidak diperlukan untuk build:

.git
/node_modules

dan jalankan build lagi:

$ time docker build -t app .
Sending build context to Docker daemon 607.2kB
Step 1/5 : FROM node:12.16.2
Step 2/5 : WORKDIR /app
 ---> Using cache
Step 3/5 : COPY . .
Step 4/5 : RUN npm ci
added 1357 packages in 22.47s
Step 5/5 : RUN npm run build --prod
Date: 2020-04-16T19:33:54.338Z - Hash: fffa0fddaa3425c55dd3 - Time: 37313ms
Successfully built 4942f010792a
Successfully tagged app:latest

real 1m47.763s
user 0m0.000s
sys 0m0.000s

607.2 KB jauh lebih baik daripada 409 MB. Kami juga mengurangi ukuran gambar dari 1.74 menjadi 1.38 GB:

REPOSITORY   TAG      IMAGE ID       CREATED         SIZE
app          latest   4942f010792a   3 minutes ago   1.38GB

Mari kita coba memperkecil ukuran gambar lebih jauh.

Kami menggunakan Alpen

Cara lain untuk menghemat ukuran gambar adalah dengan menggunakan gambar induk kecil. Citra orang tua adalah citra yang menjadi dasar penyusunan citra kita. Lapisan bawah ditentukan oleh perintah FROM di Dockerfile. Dalam kasus kami, kami menggunakan image berbasis Ubuntu yang sudah menginstal nodejs. Dan itu beratnya...

$ docker images -a | grep node
node 12.16.2 406aa3abbc6c 17 minutes ago 916MB

... hampir satu gigabyte. Anda dapat mengurangi volume secara signifikan dengan menggunakan image berbasis Alpine Linux. Alpine adalah Linux yang sangat kecil. Gambar buruh pelabuhan untuk nodejs berdasarkan alpine hanya berbobot 88.5 MB. Jadi mari kita ganti gambar hidup kita di rumah-rumah:

FROM node:12.16.2-alpine3.11
RUN apk --no-cache --update --virtual build-dependencies add 
    python 
    make 
    g++
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build --prod

Kami harus menginstal beberapa hal yang diperlukan untuk membangun aplikasi. Ya, Angular tidak dapat dibangun tanpa Python Β―(Β°_o)/Β―

Namun ukuran gambar turun menjadi 150 MB:

REPOSITORY   TAG      IMAGE ID       CREATED          SIZE
app          latest   aa031edc315a   22 minutes ago   761MB

Ayo melangkah lebih jauh.

Perakitan bertingkat

Tidak semua yang ada dalam gambar adalah apa yang kita butuhkan dalam produksi.

$ docker run app ls -lah
total 576K
drwxr-xr-x 1 root root 4.0K Apr 16 19:54 .
drwxr-xr-x 1 root root 4.0K Apr 16 20:00 ..
-rwxr-xr-x 1 root root 19 Apr 17 2020 .dockerignore
-rwxr-xr-x 1 root root 246 Apr 17 2020 .editorconfig
-rwxr-xr-x 1 root root 631 Apr 17 2020 .gitignore
-rwxr-xr-x 1 root root 181 Apr 17 2020 Dockerfile
-rwxr-xr-x 1 root root 1020 Apr 17 2020 README.md
-rwxr-xr-x 1 root root 3.6K Apr 17 2020 angular.json
-rwxr-xr-x 1 root root 429 Apr 17 2020 browserslist
drwxr-xr-x 3 root root 4.0K Apr 16 19:54 dist
drwxr-xr-x 3 root root 4.0K Apr 17 2020 e2e
-rwxr-xr-x 1 root root 1015 Apr 17 2020 karma.conf.js
-rwxr-xr-x 1 root root 620 Apr 17 2020 ngsw-config.json
drwxr-xr-x 1 root root 4.0K Apr 16 19:54 node_modules
-rwxr-xr-x 1 root root 494.9K Apr 17 2020 package-lock.json
-rwxr-xr-x 1 root root 1.3K Apr 17 2020 package.json
drwxr-xr-x 5 root root 4.0K Apr 17 2020 src
-rwxr-xr-x 1 root root 210 Apr 17 2020 tsconfig.app.json
-rwxr-xr-x 1 root root 489 Apr 17 2020 tsconfig.json
-rwxr-xr-x 1 root root 270 Apr 17 2020 tsconfig.spec.json
-rwxr-xr-x 1 root root 1.9K Apr 17 2020 tslint.json

Dengan docker run app ls -lah kami meluncurkan wadah berdasarkan gambar kami app dan menjalankan perintah di dalamnya ls -lah, setelah itu kontainer menyelesaikan pekerjaannya.

Dalam produksi kita hanya membutuhkan folder dist. Dalam hal ini, file-file tersebut perlu diberikan ke luar. Anda dapat menjalankan beberapa server HTTP di nodejs. Tapi kami akan membuatnya lebih mudah. Tebak kata Rusia yang memiliki empat huruf "y". Benar! Ynhynyksy. Mari kita ambil gambar dengan nginx, letakkan folder di dalamnya dist dan konfigurasi kecil:

server {
    listen 80 default_server;
    server_name localhost;
    charset utf-8;
    root /app/dist;

    location / {
        try_files $uri $uri/ /index.html;
    }
}

Pembangunan multi-tahap akan membantu kami melakukan semua ini. Mari kita ubah Dockerfile kita:

FROM node:12.16.2-alpine3.11 as builder
RUN apk --no-cache --update --virtual build-dependencies add 
    python 
    make 
    g++
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build --prod

FROM nginx:1.17.10-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx/static.conf /etc/nginx/conf.d
COPY --from=builder /app/dist/app .

Sekarang kami memiliki dua instruksi FROM di Dockerfile, masing-masing menjalankan langkah build yang berbeda. Kami menelepon yang pertama builder, tapi mulai dari FROM terakhir, gambar akhir kita akan disiapkan. Langkah terakhir adalah menyalin artefak perakitan kita pada langkah sebelumnya ke gambar akhir dengan nginx. Ukuran gambar telah berkurang secara signifikan:

REPOSITORY   TAG      IMAGE ID       CREATED          SIZE
app          latest   2c6c5da07802   29 minutes ago   36MB

Mari jalankan container dengan gambar kita dan pastikan semuanya berfungsi:

docker run -p8080:80 app

Dengan menggunakan opsi -p8080:80, kami meneruskan port 8080 pada mesin host kami ke port 80 di dalam container tempat nginx dijalankan. Buka di browser http://localhost:8080/ dan kami melihat aplikasi kami. Semuanya berfungsi!

Beberapa tip tentang cara mempercepat pembuatan image Docker. Misalnya hingga 30 detik

Mengurangi ukuran gambar dari 1.74 GB menjadi 36 MB secara signifikan mengurangi waktu yang diperlukan untuk mengirimkan aplikasi Anda ke produksi. Tapi mari kita kembali ke waktu berkumpul.

$ time docker build -t app .
Sending build context to Docker daemon 608.8kB
Step 1/11 : FROM node:12.16.2-alpine3.11 as builder
Step 2/11 : RUN apk --no-cache --update --virtual build-dependencies add python make g++
 ---> Using cache
Step 3/11 : WORKDIR /app
 ---> Using cache
Step 4/11 : COPY . .
Step 5/11 : RUN npm ci
added 1357 packages in 47.338s
Step 6/11 : RUN npm run build --prod
Date: 2020-04-16T21:16:03.899Z - Hash: fffa0fddaa3425c55dd3 - Time: 39948ms
 ---> 27f1479221e4
Step 7/11 : FROM nginx:stable-alpine
Step 8/11 : WORKDIR /app
 ---> Using cache
Step 9/11 : RUN rm /etc/nginx/conf.d/default.conf
 ---> Using cache
Step 10/11 : COPY nginx/static.conf /etc/nginx/conf.d
 ---> Using cache
Step 11/11 : COPY --from=builder /app/dist/app .
Successfully built d201471c91ad
Successfully tagged app:latest

real 2m17.700s
user 0m0.000s
sys 0m0.000s

Mengubah urutan lapisan

Tiga langkah pertama kami di-cache (petunjuk Using cache). Pada langkah keempat, semua file proyek disalin dan pada langkah kelima dependensi dipasang RUN npm ci - sebanyak 47.338 detik. Mengapa menginstal ulang dependensi setiap saat jika sangat jarang berubah? Mari kita cari tahu mengapa mereka tidak di-cache. Intinya Docker akan memeriksa lapis demi lapis untuk melihat apakah perintah dan file yang terkait dengannya telah berubah. Pada langkah keempat, kita menyalin semua file proyek kita, dan tentu saja ada perubahan di antara mereka, jadi Docker tidak hanya mengambil lapisan ini dari cache, tetapi juga semua lapisan berikutnya! Mari buat beberapa perubahan kecil pada Dockerfile.

FROM node:12.16.2-alpine3.11 as builder
RUN apk --no-cache --update --virtual build-dependencies add 
    python 
    make 
    g++
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build --prod

FROM nginx:1.17.10-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx/static.conf /etc/nginx/conf.d
COPY --from=builder /app/dist/app .

Pertama, package.json dan package-lock.json disalin, kemudian dependensi diinstal, dan baru setelah itu seluruh proyek disalin. Sebagai akibat:

$ time docker build -t app .
Sending build context to Docker daemon 608.8kB
Step 1/12 : FROM node:12.16.2-alpine3.11 as builder
Step 2/12 : RUN apk --no-cache --update --virtual build-dependencies add python make g++
 ---> Using cache
Step 3/12 : WORKDIR /app
 ---> Using cache
Step 4/12 : COPY package*.json ./
 ---> Using cache
Step 5/12 : RUN npm ci
 ---> Using cache
Step 6/12 : COPY . .
Step 7/12 : RUN npm run build --prod
Date: 2020-04-16T21:29:44.770Z - Hash: fffa0fddaa3425c55dd3 - Time: 38287ms
 ---> 1b9448c73558
Step 8/12 : FROM nginx:stable-alpine
Step 9/12 : WORKDIR /app
 ---> Using cache
Step 10/12 : RUN rm /etc/nginx/conf.d/default.conf
 ---> Using cache
Step 11/12 : COPY nginx/static.conf /etc/nginx/conf.d
 ---> Using cache
Step 12/12 : COPY --from=builder /app/dist/app .
Successfully built a44dd7c217c3
Successfully tagged app:latest

real 0m46.497s
user 0m0.000s
sys 0m0.000s

46 detik, bukan 3 menit - jauh lebih baik! Urutan lapisan yang benar itu penting: pertama kita menyalin apa yang tidak berubah, lalu apa yang jarang berubah, dan terakhir apa yang sering berubah.

Selanjutnya, beberapa penjelasan tentang merakit gambar dalam sistem CI/CD.

Menggunakan gambar sebelumnya untuk cache

Jika kita menggunakan semacam solusi SaaS untuk pembangunannya, maka cache Docker lokal mungkin bersih dan segar. Untuk memberikan tempat bagi buruh pelabuhan untuk mendapatkan lapisan yang dipanggang, berikan dia gambar yang dibuat sebelumnya.

Mari kita ambil contoh membangun aplikasi kita di GitHub Actions. Kami menggunakan konfigurasi ini

on:
  push:
    branches:
      - master

name: Test docker build

jobs:
  deploy:
    name: Build
    runs-on: ubuntu-latest
    env:
      IMAGE_NAME: docker.pkg.github.com/${{ github.repository }}/app
      IMAGE_TAG: ${{ github.sha }}

    steps:
    - name: Checkout
      uses: actions/checkout@v2

    - name: Login to GitHub Packages
      env:
        TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        docker login docker.pkg.github.com -u $GITHUB_ACTOR -p $TOKEN

    - name: Build
      run: |
        docker build 
          -t $IMAGE_NAME:$IMAGE_TAG 
          -t $IMAGE_NAME:latest 
          .

    - name: Push image to GitHub Packages
      run: |
        docker push $IMAGE_NAME:latest
        docker push $IMAGE_NAME:$IMAGE_TAG

    - name: Logout
      run: |
        docker logout docker.pkg.github.com

Gambar dirakit dan dikirim ke Paket GitHub dalam dua menit 20 detik:

Beberapa tip tentang cara mempercepat pembuatan image Docker. Misalnya hingga 30 detik

Sekarang mari kita ubah build sehingga cache digunakan berdasarkan gambar yang dibuat sebelumnya:

on:
  push:
    branches:
      - master

name: Test docker build

jobs:
  deploy:
    name: Build
    runs-on: ubuntu-latest
    env:
      IMAGE_NAME: docker.pkg.github.com/${{ github.repository }}/app
      IMAGE_TAG: ${{ github.sha }}

    steps:
    - name: Checkout
      uses: actions/checkout@v2

    - name: Login to GitHub Packages
      env:
        TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        docker login docker.pkg.github.com -u $GITHUB_ACTOR -p $TOKEN

    - name: Pull latest images
      run: |
        docker pull $IMAGE_NAME:latest || true
        docker pull $IMAGE_NAME-builder-stage:latest || true

    - name: Images list
      run: |
        docker images

    - name: Build
      run: |
        docker build 
          --target builder 
          --cache-from $IMAGE_NAME-builder-stage:latest 
          -t $IMAGE_NAME-builder-stage 
          .
        docker build 
          --cache-from $IMAGE_NAME-builder-stage:latest 
          --cache-from $IMAGE_NAME:latest 
          -t $IMAGE_NAME:$IMAGE_TAG 
          -t $IMAGE_NAME:latest 
          .

    - name: Push image to GitHub Packages
      run: |
        docker push $IMAGE_NAME-builder-stage:latest
        docker push $IMAGE_NAME:latest
        docker push $IMAGE_NAME:$IMAGE_TAG

    - name: Logout
      run: |
        docker logout docker.pkg.github.com

Pertama kami perlu memberi tahu Anda mengapa dua perintah diluncurkan build. Faktanya adalah bahwa dalam perakitan multitahap, gambar yang dihasilkan akan menjadi sekumpulan lapisan dari tahap terakhir. Dalam hal ini, lapisan dari lapisan sebelumnya tidak akan disertakan dalam gambar. Oleh karena itu, saat menggunakan image akhir dari build sebelumnya, Docker tidak akan dapat menemukan lapisan yang siap untuk membuat image dengan nodejs (tahap pembuat). Untuk mengatasi masalah ini, gambar perantara dibuat $IMAGE_NAME-builder-stage dan didorong ke Paket GitHub sehingga dapat digunakan pada build berikutnya sebagai sumber cache.

Beberapa tip tentang cara mempercepat pembuatan image Docker. Misalnya hingga 30 detik

Total waktu perakitan dikurangi menjadi satu setengah menit. Setengah menit dihabiskan untuk melihat gambar sebelumnya.

Pencitraan awal

Cara lain untuk mengatasi masalah cache Docker yang bersih adalah dengan memindahkan beberapa lapisan ke Dockerfile lain, membangunnya secara terpisah, memasukkannya ke dalam Container Registry dan menggunakannya sebagai induk.

Kami membuat gambar nodejs kami sendiri untuk membangun aplikasi Angular. Buat Dockerfile.node di proyek

FROM node:12.16.2-alpine3.11
RUN apk --no-cache --update --virtual build-dependencies add 
    python 
    make 
    g++

Kami mengumpulkan dan mendorong image publik di Docker Hub:

docker build -t exsmund/node-for-angular -f Dockerfile.node .
docker push exsmund/node-for-angular:latest

Sekarang di Dockerfile utama kami, kami menggunakan gambar yang sudah jadi:

FROM exsmund/node-for-angular:latest as builder
...

Dalam contoh kita, waktu pembuatan tidak berkurang, tetapi gambar yang dibuat sebelumnya dapat berguna jika Anda memiliki banyak proyek dan harus menginstal dependensi yang sama di masing-masing proyek.

Beberapa tip tentang cara mempercepat pembuatan image Docker. Misalnya hingga 30 detik

Kami melihat beberapa metode untuk mempercepat pembuatan image buruh pelabuhan. Jika Anda ingin penerapan berjalan cepat, coba gunakan ini di proyek Anda:

  • mengurangi konteks;
  • penggunaan gambar induk kecil;
  • perakitan bertingkat;
  • mengubah urutan instruksi di Dockerfile untuk memanfaatkan cache secara efisien;
  • menyiapkan cache di sistem CI/CD;
  • pembuatan gambar awal.

Saya harap contoh ini akan memperjelas cara kerja Docker, dan Anda akan dapat mengonfigurasi penerapan Anda secara optimal. Untuk bermain-main dengan contoh dari artikel, repositori telah dibuat https://github.com/devopsprodigy/test-docker-build.

Sumber: www.habr.com

Tambah komentar