Menjalankan Keycloak dalam mod HA pada Kubernetes

Menjalankan Keycloak dalam mod HA pada Kubernetes

TL; DR: akan ada penerangan mengenai Keycloak, sistem kawalan akses sumber terbuka, analisis struktur dalaman, butiran konfigurasi.

Pengenalan dan Idea Utama

Dalam artikel ini, kita akan melihat idea asas yang perlu diingat semasa menggunakan kluster Keycloak di atas Kubernetes.

Jika anda ingin mengetahui lebih lanjut tentang Keycloak, rujuk pautan di penghujung artikel. Untuk menjadi lebih mendalami amalan, anda boleh belajar repositori kami dengan modul yang melaksanakan idea utama artikel ini (panduan pelancaran ada di sana, artikel ini akan memberikan gambaran keseluruhan peranti dan tetapan, lebih kurang penterjemah).

Keycloak ialah sistem komprehensif yang ditulis dalam Java dan dibina di atas pelayan aplikasi Lalat liar. Ringkasnya, ia adalah rangka kerja untuk kebenaran yang memberikan pengguna aplikasi persekutuan dan keupayaan SSO (daftar masuk tunggal).

Kami menjemput anda untuk membaca rasmi laman web atau Wikipedia untuk pemahaman yang terperinci.

Melancarkan Keycloak

Keycloak memerlukan dua sumber data yang berterusan untuk dijalankan:

  • Pangkalan data yang digunakan untuk menyimpan data yang telah ditetapkan, seperti maklumat pengguna
  • Cache Datagrid, yang digunakan untuk cache data daripada pangkalan data, serta untuk menyimpan beberapa metadata jangka pendek dan kerap berubah, seperti sesi pengguna. Dilaksanakan Infinispan, yang biasanya jauh lebih pantas daripada pangkalan data. Tetapi dalam apa jua keadaan, data yang disimpan dalam Infinispan adalah fana - dan ia tidak perlu disimpan di mana-mana apabila kluster dimulakan semula.

Keycloak berfungsi dalam empat mod berbeza:

  • biasa - satu dan hanya satu proses, dikonfigurasikan melalui fail standalone.xml
  • Kelompok biasa (pilihan ketersediaan tinggi) - semua proses mesti menggunakan konfigurasi yang sama, yang mesti disegerakkan secara manual. Tetapan disimpan dalam fail standalone-ha.xml, di samping itu anda perlu membuat akses dikongsi kepada pangkalan data dan pengimbang beban.
  • Kelompok domain β€” memulakan kluster dalam mod biasa dengan cepat menjadi tugas rutin dan membosankan apabila kluster berkembang, kerana setiap kali konfigurasi berubah, semua perubahan mesti dibuat pada setiap nod kluster. Mod operasi domain menyelesaikan isu ini dengan menyediakan beberapa lokasi storan kongsi dan menerbitkan konfigurasi. Tetapan ini disimpan dalam fail domain.xml
  • Replikasi antara pusat data β€” jika anda ingin menjalankan Keycloak dalam kelompok beberapa pusat data, selalunya di lokasi geografi yang berbeza. Dalam pilihan ini, setiap pusat data akan mempunyai kluster pelayan Keycloak sendiri.

Dalam artikel ini kita akan mempertimbangkan secara terperinci pilihan kedua, iaitu kluster biasa, dan kami juga akan menyentuh sedikit tentang topik replikasi antara pusat data, kerana ia masuk akal untuk menjalankan dua pilihan ini dalam Kubernetes. Nasib baik, dalam Kubernetes tidak ada masalah dengan menyegerakkan tetapan beberapa pod (nod Keycloak), jadi kelompok domain Ia tidak akan menjadi sangat sukar untuk dilakukan.

Juga sila ambil perhatian bahawa perkataan gugusan untuk artikel selebihnya akan digunakan semata-mata untuk sekumpulan nod Keycloak yang bekerja bersama, tidak perlu merujuk kepada gugusan Kubernetes.

Kelompok Keycloak biasa

Untuk menjalankan Keycloak dalam mod ini anda perlukan:

  • mengkonfigurasi pangkalan data kongsi luaran
  • pasang pengimbang beban
  • mempunyai rangkaian dalaman dengan sokongan multicast IP

Kami tidak akan membincangkan penyediaan pangkalan data luaran, kerana ia bukan tujuan artikel ini. Mari kita anggap bahawa terdapat pangkalan data yang berfungsi di suatu tempat - dan kami mempunyai titik sambungan kepadanya. Kami hanya akan menambah data ini kepada pembolehubah persekitaran.

Untuk lebih memahami cara Keycloak berfungsi dalam gugusan failover (HA), adalah penting untuk mengetahui sejauh mana ia bergantung pada keupayaan pengelompokan Wildfly.

Wildfly menggunakan beberapa subsistem, sebahagian daripadanya digunakan sebagai pengimbang beban, sesetengahnya untuk toleransi kesalahan. Pengimbang beban memastikan ketersediaan aplikasi apabila nod kluster dibebankan, dan toleransi kesalahan memastikan ketersediaan aplikasi walaupun beberapa nod kluster gagal. Beberapa subsistem ini:

  • mod_cluster: Berfungsi bersama Apache sebagai pengimbang beban HTTP, bergantung pada TCP multicast untuk mencari hos secara lalai. Boleh digantikan dengan pengimbang luaran.

  • infinispan: Cache teragih menggunakan saluran JGroups sebagai lapisan pengangkutan. Selain itu, ia boleh menggunakan protokol HotRod untuk berkomunikasi dengan kluster Infinispan luaran untuk menyegerakkan kandungan cache.

  • jgroups: Menyediakan sokongan komunikasi kumpulan untuk perkhidmatan yang sangat tersedia berdasarkan saluran JGroups. Paip bernama membenarkan contoh aplikasi dalam kelompok disambungkan ke dalam kumpulan supaya komunikasi mempunyai sifat seperti kebolehpercayaan, keteraturan dan kepekaan terhadap kegagalan.

Pengimbang Beban

Apabila memasang pengimbang sebagai pengawal kemasukan dalam gugusan Kubernetes, adalah penting untuk mengingati perkara berikut:

Keycloak menganggap bahawa alamat jauh klien yang menyambung melalui HTTP ke pelayan pengesahan ialah alamat IP sebenar komputer klien. Tetapan pengimbang dan kemasukan harus menetapkan pengepala HTTP dengan betul X-Forwarded-For ΠΈ X-Forwarded-Proto, dan juga menyimpan tajuk asal HOST. Versi terkini ingress-nginx (>0.22.0) melumpuhkan ini secara lalai

Mengaktifkan bendera proxy-address-forwarding dengan menetapkan pembolehubah persekitaran PROXY_ADDRESS_FORWARDING Π² true memberikan Keycloak pemahaman bahawa ia berfungsi di sebalik proksi.

Anda juga perlu mengaktifkan sesi melekit dalam kemasukan. Keycloak menggunakan cache Infinispan yang diedarkan untuk menyimpan data yang dikaitkan dengan sesi pengesahan semasa dan sesi pengguna. Cache beroperasi dengan pemilik tunggal secara lalai, dengan kata lain, sesi tertentu itu disimpan pada beberapa nod dalam kelompok, dan nod lain mesti menanyakannya dari jauh jika mereka memerlukan akses kepada sesi itu.

Khususnya, bertentangan dengan dokumentasi, melampirkan sesi dengan kuki nama tidak berfungsi untuk kami AUTH_SESSION_ID. Keycloak mempunyai gelung ubah hala, jadi kami mengesyorkan memilih nama kuki yang berbeza untuk sesi melekit.

Keycloak juga melampirkan nama nod yang bertindak balas terlebih dahulu AUTH_SESSION_ID, dan oleh kerana setiap nod dalam versi yang sangat tersedia menggunakan pangkalan data yang sama, setiap satu daripadanya perlu ada pengecam nod yang berasingan dan unik untuk menguruskan urus niaga. Adalah disyorkan untuk dimasukkan ke dalam JAVA_OPTS parameter jboss.node.name ΠΈ jboss.tx.node.id unik untuk setiap nod - anda boleh, sebagai contoh, meletakkan nama pod. Jika anda meletakkan nama pod, jangan lupa tentang had 23 aksara untuk pembolehubah jboss, jadi lebih baik menggunakan StatefulSet daripada Deployment.

Satu lagi garu - jika pod dipadamkan atau dimulakan semula, cachenya hilang. Dengan mengambil kira perkara ini, adalah wajar menetapkan bilangan pemilik cache untuk semua cache kepada sekurang-kurangnya dua, supaya salinan cache akan kekal. Penyelesaiannya adalah dengan berlari skrip untuk Wildfly apabila memulakan pod, meletakkannya dalam direktori /opt/jboss/startup-scripts dalam bekas:

Kandungan Skrip

embed-server --server-config=standalone-ha.xml --std-out=echo
batch

echo * Setting CACHE_OWNERS to "${env.CACHE_OWNERS}" in all cache-containers

/subsystem=infinispan/cache-container=keycloak/distributed-cache=sessions:write-attribute(name=owners, value=${env.CACHE_OWNERS:1})
/subsystem=infinispan/cache-container=keycloak/distributed-cache=authenticationSessions:write-attribute(name=owners, value=${env.CACHE_OWNERS:1})
/subsystem=infinispan/cache-container=keycloak/distributed-cache=actionTokens:write-attribute(name=owners, value=${env.CACHE_OWNERS:1})
/subsystem=infinispan/cache-container=keycloak/distributed-cache=offlineSessions:write-attribute(name=owners, value=${env.CACHE_OWNERS:1})
/subsystem=infinispan/cache-container=keycloak/distributed-cache=clientSessions:write-attribute(name=owners, value=${env.CACHE_OWNERS:1})
/subsystem=infinispan/cache-container=keycloak/distributed-cache=offlineClientSessions:write-attribute(name=owners, value=${env.CACHE_OWNERS:1})
/subsystem=infinispan/cache-container=keycloak/distributed-cache=loginFailures:write-attribute(name=owners, value=${env.CACHE_OWNERS:1})

run-batch
stop-embedded-server

kemudian tetapkan nilai pembolehubah persekitaran CACHE_OWNERS kepada yang diperlukan.

Rangkaian peribadi dengan sokongan multicast IP

Jika anda menggunakan Weavenet sebagai CNI, multicast akan berfungsi serta-merta - dan nod Keycloak anda akan melihat satu sama lain sebaik sahaja ia dilancarkan.

Jika anda tidak mempunyai sokongan multicast ip dalam kelompok Kubernetes anda, anda boleh mengkonfigurasi JGroups untuk bekerja dengan protokol lain untuk mencari nod.

Pilihan pertama ialah menggunakan KUBE_DNSyang menggunakan headless service untuk mencari nod Keycloak, anda hanya lulus JGroups nama perkhidmatan yang akan digunakan untuk mencari nod.

Pilihan lain ialah menggunakan kaedah tersebut KUBE_PING, yang berfungsi dengan API untuk mencari nod (anda perlu mengkonfigurasi serviceAccount dengan hak list ΠΈ get, dan kemudian konfigurasikan pod untuk berfungsi dengan ini serviceAccount).

Cara JGroups mencari nod dikonfigurasikan dengan menetapkan pembolehubah persekitaran JGROUPS_DISCOVERY_PROTOCOL ΠΈ JGROUPS_DISCOVERY_PROPERTIES. Untuk KUBE_PING anda perlu memilih pod dengan bertanya namespace ΠΈ labels.

️ Jika anda menggunakan multicast dan menjalankan dua atau lebih kluster Keycloak dalam satu kluster Kubernetes (katakan satu dalam ruang nama production, kedua - staging) - nod satu kluster Keycloak boleh menyertai kluster lain. Pastikan anda menggunakan alamat multicast yang unik untuk setiap kelompok dengan menetapkan pembolehubahjboss.default.multicast.address и jboss.modcluster.multicast.address в JAVA_OPTS.

Replikasi antara pusat data

Menjalankan Keycloak dalam mod HA pada Kubernetes

Бвязь

Keycloak menggunakan berbilang kluster cache Infinispan berasingan untuk setiap pusat data di mana kluster Keycloak yang terdiri daripada nod Keycloak berada. Tetapi tiada perbezaan antara nod Keycloak dalam pusat data yang berbeza.

Nod keycloak menggunakan Grid Data Java luaran (pelayan Infinispan) untuk komunikasi antara pusat data. Komunikasi berfungsi mengikut protokol Infinispan HotRod.

Cache Infinispan mesti dikonfigurasikan dengan atribut remoteStore, supaya data boleh disimpan dari jauh (dalam pusat data lain, lebih kurang penterjemah) cache. Terdapat kluster infinispan yang berasingan antara pelayan JDG, supaya data disimpan pada JDG1 di tapak site1 akan direplikasi kepada JDG2 di tapak site2.

Dan akhirnya, pelayan JDG yang menerima memberitahu pelayan Keycloak klusternya melalui sambungan klien, yang merupakan ciri protokol HotRod. Nod penutup kunci dihidupkan site2 kemas kini cache Infinispan mereka dan sesi pengguna tertentu turut tersedia pada nod Keycloak pada site2.

Untuk sesetengah cache, adalah mungkin untuk tidak membuat sandaran dan mengelak daripada menulis data melalui pelayan Infinispan sepenuhnya. Untuk melakukan ini, anda perlu mengalih keluar tetapan remote-store cache Infinispan tertentu (dalam fail standalone-ha.xml), selepas itu beberapa khusus replicated-cache juga tidak lagi diperlukan di bahagian pelayan Infinispan.

Menyediakan cache

Terdapat dua jenis cache dalam Keycloak:

  • Tempatan. Ia terletak di sebelah pangkalan data dan berfungsi untuk mengurangkan beban pada pangkalan data, serta mengurangkan kependaman respons. Jenis cache ini menyimpan alam, klien, peranan dan metadata pengguna. Jenis cache ini tidak direplikasi, walaupun cache adalah sebahagian daripada gugusan Keycloak. Jika entri dalam cache berubah, mesej tentang perubahan itu dihantar ke pelayan yang tinggal dalam kelompok, selepas itu entri itu dikecualikan daripada cache. Lihat penerangan work Lihat di bawah untuk penerangan yang lebih terperinci mengenai prosedur.

  • Direplikasi. Memproses sesi pengguna, token luar talian dan juga memantau ralat log masuk untuk mengesan percubaan pancingan data kata laluan dan serangan lain. Data yang disimpan dalam cache ini adalah sementara, disimpan hanya dalam RAM, tetapi boleh direplikasi merentas kluster.

Infinispan cache

Sesi - konsep dalam Keycloak, cache berasingan dipanggil authenticationSessions, digunakan untuk menyimpan data pengguna tertentu. Permintaan daripada cache ini biasanya diperlukan oleh penyemak imbas dan pelayan Keycloak, bukan oleh aplikasi. Di sinilah pergantungan pada sesi melekit dimainkan, dan cache sedemikian sendiri tidak perlu direplikasi, walaupun dalam kes mod Aktif-Aktif.

Token Tindakan. Konsep lain, biasanya digunakan untuk pelbagai senario apabila, sebagai contoh, pengguna mesti melakukan sesuatu secara tidak segerak melalui mel. Sebagai contoh, semasa prosedur forget password cache actionTokens digunakan untuk menjejak metadata token yang berkaitan - contohnya, token telah digunakan dan tidak boleh diaktifkan semula. Jenis cache ini biasanya perlu direplikasi antara pusat data.

Caching dan penuaan data yang disimpan berfungsi untuk meringankan beban pada pangkalan data. Caching jenis ini meningkatkan prestasi, tetapi menambah masalah yang jelas. Jika satu pelayan Keycloak mengemas kini data, pelayan lain mesti dimaklumkan supaya mereka boleh mengemas kini data dalam cache mereka. Keycloak menggunakan cache tempatan realms, users ΠΈ authorization untuk menyimpan data daripada pangkalan data.

Terdapat juga cache yang berasingan work, yang direplikasi merentasi semua pusat data. Ia sendiri tidak menyimpan sebarang data daripada pangkalan data, tetapi berfungsi untuk menghantar mesej tentang penuaan data kepada nod kelompok antara pusat data. Dalam erti kata lain, sebaik sahaja data dikemas kini, nod Keycloak menghantar mesej kepada nod lain dalam pusat datanya, serta nod dalam pusat data lain. Selepas menerima mesej sedemikian, setiap nod mengosongkan data yang sepadan dalam cache setempatnya.

Sesi pengguna. Cache dengan nama sessions, clientSessions, offlineSessions ΠΈ offlineClientSessions, biasanya direplikasi antara pusat data dan berfungsi untuk menyimpan data tentang sesi pengguna yang aktif semasa pengguna aktif dalam penyemak imbas. Cache ini berfungsi dengan aplikasi memproses permintaan HTTP daripada pengguna akhir, jadi ia dikaitkan dengan sesi melekit dan mesti direplikasi antara pusat data.

Perlindungan kekerasan. Cache loginFailures Digunakan untuk menjejak data ralat log masuk, seperti berapa kali pengguna memasukkan kata laluan yang salah. Replikasi cache ini adalah tanggungjawab pentadbir. Tetapi untuk pengiraan yang tepat, adalah wajar mengaktifkan replikasi antara pusat data. Tetapi sebaliknya, jika anda tidak meniru data ini, anda akan meningkatkan prestasi, dan jika isu ini timbul, replikasi mungkin tidak diaktifkan.

Apabila melancarkan kluster Infinispan, anda perlu menambah definisi cache pada fail tetapan:

<replicated-cache-configuration name="keycloak-sessions" mode="ASYNC" start="EAGER" batching="false">
</replicated-cache-configuration>

<replicated-cache name="work" configuration="keycloak-sessions" />
<replicated-cache name="sessions" configuration="keycloak-sessions" />
<replicated-cache name="offlineSessions" configuration="keycloak-sessions" />
<replicated-cache name="actionTokens" configuration="keycloak-sessions" />
<replicated-cache name="loginFailures" configuration="keycloak-sessions" />
<replicated-cache name="clientSessions" configuration="keycloak-sessions" />
<replicated-cache name="offlineClientSessions" configuration="keycloak-sessions" />

Anda mesti mengkonfigurasi dan memulakan kluster Infinispan sebelum memulakan kluster Keycloak

Kemudian anda perlu mengkonfigurasi remoteStore untuk cache Keycloak. Untuk melakukan ini, skrip sudah cukup, yang dilakukan sama dengan yang sebelumnya, yang digunakan untuk menetapkan pembolehubah CACHE_OWNERS, anda perlu menyimpannya ke fail dan meletakkannya dalam direktori /opt/jboss/startup-scripts:

Kandungan Skrip

embed-server --server-config=standalone-ha.xml --std-out=echo
batch

echo *** Update infinispan subsystem ***
/subsystem=infinispan/cache-container=keycloak:write-attribute(name=module, value=org.keycloak.keycloak-model-infinispan)

echo ** Add remote socket binding to infinispan server **
/socket-binding-group=standard-sockets/remote-destination-outbound-socket-binding=remote-cache:add(host=${remote.cache.host:localhost}, port=${remote.cache.port:11222})

echo ** Update replicated-cache work element **
/subsystem=infinispan/cache-container=keycloak/replicated-cache=work/store=remote:add( 
    passivation=false, 
    fetch-state=false, 
    purge=false, 
    preload=false, 
    shared=true, 
    remote-servers=["remote-cache"], 
    cache=work, 
    properties={ 
        rawValues=true, 
        marshaller=org.keycloak.cluster.infinispan.KeycloakHotRodMarshallerFactory, 
        protocolVersion=${keycloak.connectionsInfinispan.hotrodProtocolVersion} 
    } 
)

/subsystem=infinispan/cache-container=keycloak/replicated-cache=work:write-attribute(name=statistics-enabled,value=true)

echo ** Update distributed-cache sessions element **
/subsystem=infinispan/cache-container=keycloak/distributed-cache=sessions/store=remote:add( 
    passivation=false, 
    fetch-state=false, 
    purge=false, 
    preload=false, 
    shared=true, 
    remote-servers=["remote-cache"], 
    cache=sessions, 
    properties={ 
        rawValues=true, 
        marshaller=org.keycloak.cluster.infinispan.KeycloakHotRodMarshallerFactory, 
        protocolVersion=${keycloak.connectionsInfinispan.hotrodProtocolVersion} 
    } 
)
/subsystem=infinispan/cache-container=keycloak/distributed-cache=sessions:write-attribute(name=statistics-enabled,value=true)

echo ** Update distributed-cache offlineSessions element **
/subsystem=infinispan/cache-container=keycloak/distributed-cache=offlineSessions/store=remote:add( 
    passivation=false, 
    fetch-state=false, 
    purge=false, 
    preload=false, 
    shared=true, 
    remote-servers=["remote-cache"], 
    cache=offlineSessions, 
    properties={ 
        rawValues=true, 
        marshaller=org.keycloak.cluster.infinispan.KeycloakHotRodMarshallerFactory, 
        protocolVersion=${keycloak.connectionsInfinispan.hotrodProtocolVersion} 
    } 
)
/subsystem=infinispan/cache-container=keycloak/distributed-cache=offlineSessions:write-attribute(name=statistics-enabled,value=true)

echo ** Update distributed-cache clientSessions element **
/subsystem=infinispan/cache-container=keycloak/distributed-cache=clientSessions/store=remote:add( 
    passivation=false, 
    fetch-state=false, 
    purge=false, 
    preload=false, 
    shared=true, 
    remote-servers=["remote-cache"], 
    cache=clientSessions, 
    properties={ 
        rawValues=true, 
        marshaller=org.keycloak.cluster.infinispan.KeycloakHotRodMarshallerFactory, 
        protocolVersion=${keycloak.connectionsInfinispan.hotrodProtocolVersion} 
    } 
)
/subsystem=infinispan/cache-container=keycloak/distributed-cache=clientSessions:write-attribute(name=statistics-enabled,value=true)

echo ** Update distributed-cache offlineClientSessions element **
/subsystem=infinispan/cache-container=keycloak/distributed-cache=offlineClientSessions/store=remote:add( 
    passivation=false, 
    fetch-state=false, 
    purge=false, 
    preload=false, 
    shared=true, 
    remote-servers=["remote-cache"], 
    cache=offlineClientSessions, 
    properties={ 
        rawValues=true, 
        marshaller=org.keycloak.cluster.infinispan.KeycloakHotRodMarshallerFactory, 
        protocolVersion=${keycloak.connectionsInfinispan.hotrodProtocolVersion} 
    } 
)
/subsystem=infinispan/cache-container=keycloak/distributed-cache=offlineClientSessions:write-attribute(name=statistics-enabled,value=true)

echo ** Update distributed-cache loginFailures element **
/subsystem=infinispan/cache-container=keycloak/distributed-cache=loginFailures/store=remote:add( 
    passivation=false, 
    fetch-state=false, 
    purge=false, 
    preload=false, 
    shared=true, 
    remote-servers=["remote-cache"], 
    cache=loginFailures, 
    properties={ 
        rawValues=true, 
        marshaller=org.keycloak.cluster.infinispan.KeycloakHotRodMarshallerFactory, 
        protocolVersion=${keycloak.connectionsInfinispan.hotrodProtocolVersion} 
    } 
)
/subsystem=infinispan/cache-container=keycloak/distributed-cache=loginFailures:write-attribute(name=statistics-enabled,value=true)

echo ** Update distributed-cache actionTokens element **
/subsystem=infinispan/cache-container=keycloak/distributed-cache=actionTokens/store=remote:add( 
    passivation=false, 
    fetch-state=false, 
    purge=false, 
    preload=false, 
    shared=true, 
    cache=actionTokens, 
    remote-servers=["remote-cache"], 
    properties={ 
        rawValues=true, 
        marshaller=org.keycloak.cluster.infinispan.KeycloakHotRodMarshallerFactory, 
        protocolVersion=${keycloak.connectionsInfinispan.hotrodProtocolVersion} 
    } 
)
/subsystem=infinispan/cache-container=keycloak/distributed-cache=actionTokens:write-attribute(name=statistics-enabled,value=true)

echo ** Update distributed-cache authenticationSessions element **
/subsystem=infinispan/cache-container=keycloak/distributed-cache=authenticationSessions:write-attribute(name=statistics-enabled,value=true)

echo *** Update undertow subsystem ***
/subsystem=undertow/server=default-server/http-listener=default:write-attribute(name=proxy-address-forwarding,value=true)

run-batch
stop-embedded-server

Jangan lupa pasang JAVA_OPTS untuk nod Keycloak menjalankan HotRod: remote.cache.host, remote.cache.port dan nama perkhidmatan jboss.site.name.

Pautan dan dokumentasi tambahan

Artikel itu telah diterjemahkan dan disediakan untuk Habr oleh pekerja Pusat latihan Slurm β€” kursus intensif, kursus video dan latihan korporat daripada pakar amalan (Kubernetes, DevOps, Docker, Ansible, Ceph, SRE)

Sumber: www.habr.com

Tambah komen